diff --git a/apps/studio/index.html b/apps/studio/index.html index d44c0d682d..0f47adb5b8 100644 --- a/apps/studio/index.html +++ b/apps/studio/index.html @@ -3,7 +3,7 @@ - + WordPress Studio diff --git a/apps/studio/package.json b/apps/studio/package.json index 6f5694af4a..80e4a2fdd1 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -30,6 +30,8 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@automattic/agenttic-client": "0.1.63", + "@automattic/agenttic-ui": "0.1.63", "@automattic/generate-password": "^0.1.0", "@automattic/interpolate-components": "^1.2.1", "@formatjs/intl-locale": "^3.4.5", diff --git a/apps/studio/postcss.config.js b/apps/studio/postcss.config.js index 4b65759eb7..07e34ba294 100644 --- a/apps/studio/postcss.config.js +++ b/apps/studio/postcss.config.js @@ -1,9 +1,31 @@ const path = require( 'path' ); +const tailwindcss = require( 'tailwindcss' ); + +const preserveAgentticBaseLayer = { + postcssPlugin: 'preserve-agenttic-base-layer', + Once( root ) { + const inputPath = root.source?.input.file ?? ''; + const isAgentticCss = inputPath.includes( + `${ path.sep }@automattic${ path.sep }agenttic-ui${ path.sep }` + ); + + if ( ! isAgentticCss ) { + return; + } + + root.walkAtRules( 'layer', ( rule ) => { + if ( rule.params === 'base' && rule.nodes ) { + rule.params = 'agenttic-base'; + } + } ); + }, +}; module.exports = { - plugins: { - tailwindcss: { + plugins: [ + preserveAgentticBaseLayer, + tailwindcss( { config: path.join( __dirname, 'tailwind.config.js' ), - }, - }, + } ), + ], }; diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index c78ec9a787..c0a96148e3 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -24,6 +24,7 @@ import { UserSettings } from 'src/modules/user-settings'; import { useKonamiCode } from 'src/modules/wapuu-world/use-konami-code'; import { WapuuWorldGame } from 'src/modules/wapuu-world/wapuu-world-game'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; import { useAppDispatch, useRootSelector } from 'src/stores'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; import { syncOperationsThunks } from 'src/stores/sync'; @@ -41,7 +42,10 @@ export default function App() { ); const { showWhatsNew, closeWhatsNew } = useWhatsNew(); const { sites: localSites, loadingSites } = useSiteDetails(); - const isEmpty = ! loadingSites && ! localSites.length; + const { enableWorkspaces, workspaces, isLoading: isLoadingWorkspaces } = useWorkspaceSelection(); + const isEmpty = enableWorkspaces + ? ! loadingSites && ! isLoadingWorkspaces && ! workspaces.length + : ! loadingSites && ! localSites.length; const shouldShowWhatsNew = showWhatsNew && ! isEmpty; const { client } = useAuth(); const dispatch = useAppDispatch(); diff --git a/apps/studio/src/components/main-sidebar.tsx b/apps/studio/src/components/main-sidebar.tsx index 235e8b76f4..e7488dd37f 100644 --- a/apps/studio/src/components/main-sidebar.tsx +++ b/apps/studio/src/components/main-sidebar.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import { RunningSites } from 'src/components/running-sites'; import SiteMenu from 'src/components/site-menu'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { isMac } from 'src/lib/app-globals'; import { cx } from 'src/lib/cx'; @@ -13,6 +14,7 @@ interface MainSidebarProps { export default function MainSidebar( { className, style }: MainSidebarProps ) { const { sites: localSites } = useSiteDetails(); + const { enableWorkspaces } = useFeatureFlags(); return (
- { ! localSites.length ? ( + { ! localSites.length && ! enableWorkspaces ? (
{ __( 'Your sites will show up here once you create them' ) }
diff --git a/apps/studio/src/components/root.tsx b/apps/studio/src/components/root.tsx index d23e6b0959..3386308f6e 100644 --- a/apps/studio/src/components/root.tsx +++ b/apps/studio/src/components/root.tsx @@ -15,6 +15,7 @@ import { ImportExportProvider } from 'src/hooks/use-import-export'; import { SiteDetailsProvider } from 'src/hooks/use-site-details'; import { ThemeDetailsProvider } from 'src/hooks/use-theme-details'; import { OnboardingProvider } from 'src/modules/onboarding/hooks/use-onboarding'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; import { initializeUserLocale } from 'src/stores/i18n-slice'; @@ -39,13 +40,15 @@ const Root = () => { - - - - - - - + + + + + + + + + diff --git a/apps/studio/src/components/site-content-tabs.tsx b/apps/studio/src/components/site-content-tabs.tsx index 6468fb5c59..22f8ff27e7 100644 --- a/apps/studio/src/components/site-content-tabs.tsx +++ b/apps/studio/src/components/site-content-tabs.tsx @@ -11,15 +11,18 @@ import { SiteIsBeingCreated } from 'src/components/site-is-being-created'; import { MIN_WIDTH_CLASS_TO_MEASURE } from 'src/constants'; import { TabName } from 'src/hooks/use-content-tabs'; import { useEffectiveTab } from 'src/hooks/use-effective-tab'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { ContentTabSync } from 'src/modules/sync'; +import { WorkspaceContentShell } from 'src/modules/workspaces/components/workspace-content-shell'; export function SiteContentTabs() { const { selectedSite, siteCreationMessages } = useSiteDetails(); const { importState } = useImportExport(); const { effectiveTab, selectedTab, setSelectedTab, tabs } = useEffectiveTab(); + const { enableWorkspaces } = useFeatureFlags(); const { __ } = useI18n(); // Remount: Avoid focus loss on user tab changes (no remount), @@ -55,6 +58,10 @@ export function SiteContentTabs() { prevSelectedTab.current = selectedTab; }, [ selectedTab ] ); + if ( enableWorkspaces ) { + return ; + } + if ( ! selectedSite ) { return (
diff --git a/apps/studio/src/components/site-menu.tsx b/apps/studio/src/components/site-menu.tsx index 08f79f7b06..0086d0f02d 100644 --- a/apps/studio/src/components/site-menu.tsx +++ b/apps/studio/src/components/site-menu.tsx @@ -14,6 +14,12 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; import { getTerminalName } from 'src/modules/user-settings/lib/terminal'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; +import { WorkspaceSidebarRow } from 'src/modules/workspaces/components/workspace-sidebar-row'; +import { + createWorkspaceDollyWorkspaceDescriptor, + setSelectedWorkspaceDollyConversationId, +} from 'src/modules/workspaces/lib/dolly/session'; import { useRootSelector } from 'src/stores'; import { useGetUserEditorQuery, useGetUserTerminalQuery } from 'src/stores/installed-apps-api'; import { syncOperationsSelectors } from 'src/stores/sync'; @@ -257,6 +263,14 @@ export default function SiteMenu( { className }: SiteMenuProps ) { const { setSelectedTab } = useContentTabs(); const { handleDeleteSite } = useDeleteSite(); const { data: editor } = useGetUserEditorQuery(); + const { + enableWorkspaces, + workspaces: sidebarWorkspaces, + isLoading: isLoadingWorkspaces, + selectedWorkspaceId, + selectWorkspace, + selectWorkspaceTab, + } = useWorkspaceSelection(); const [ draggedIndex, setDraggedIndex ] = useState< number | null >( null ); const [ dragOverIndex, setDragOverIndex ] = useState< number | null >( null ); @@ -294,6 +308,18 @@ export default function SiteMenu( { className }: SiteMenuProps ) { setDragOverIndex( null ); }; + const selectWorkspaceChat = ( + workspace: ( typeof sidebarWorkspaces )[ number ], + conversationId: string + ) => { + selectWorkspace( workspace.id ); + selectWorkspaceTab( workspace.id, 'assistant' ); + setSelectedWorkspaceDollyConversationId( + createWorkspaceDollyWorkspaceDescriptor( workspace ), + conversationId + ); + }; + useEffect( () => { const unsubscribe = window.ipcListener.subscribe( 'site-context-menu-action', @@ -392,24 +418,57 @@ export default function SiteMenu( { className }: SiteMenuProps ) { ) } >
    - { sites.map( ( site, index ) => ( - - ) ) } - { /* Drop zone for dragging to bottom of list */ } -
  • handleDragOver( e, sites.length ) } - onDrop={ ( e ) => handleDrop( e, sites.length ) } - /> + { enableWorkspaces ? ( + <> + { sidebarWorkspaces.map( ( workspace ) => ( + + ) : undefined + } + onSelect={ () => selectWorkspace( workspace.id ) } + onSelectChat={ ( conversationId ) => + selectWorkspaceChat( workspace, conversationId ) + } + /> + ) ) } + { isLoadingWorkspaces && ( +
  • + { __( 'Loading...' ) } +
  • + ) } + + ) : ( + <> + { sites.map( ( site, index ) => ( + + ) ) } + { /* Drop zone for dragging to bottom of list */ } +
  • handleDragOver( e, sites.length ) } + onDrop={ ( e ) => handleDrop( e, sites.length ) } + /> + + ) }
); diff --git a/apps/studio/src/components/tests/app.test.tsx b/apps/studio/src/components/tests/app.test.tsx index 5af46df866..0cb38d7349 100644 --- a/apps/studio/src/components/tests/app.test.tsx +++ b/apps/studio/src/components/tests/app.test.tsx @@ -6,6 +6,7 @@ import App from 'src/components/app'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { rootReducer } from 'src/stores'; import { appVersionApi } from 'src/stores/app-version-api'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; @@ -14,11 +15,25 @@ import { connectedSitesApi } from 'src/stores/sync/connected-sites'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import { wordpressVersionsApi } from 'src/stores/wordpress-versions-api'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import type { SyncSite } from '@studio/common/types/sync'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); vi.mock( 'src/index.css', () => ( {} ) ); +vi.mock( 'src/modules/workspaces/components/workspace-dolly-assistant', () => ( { + WorkspaceDollyAssistant: () => null, +} ) ); vi.mock( 'src/components/dot-grid', () => ( { DotGrid: () => null, } ) ); +vi.mock( 'src/components/gravatar', () => ( { + Gravatar: () => null, +} ) ); vi.mock( 'src/stores/onboarding-slice', async () => { const actual = await vi.importActual( 'src/stores/onboarding-slice' ); return { @@ -28,6 +43,18 @@ vi.mock( 'src/stores/onboarding-slice', async () => { } ); vi.mock( 'src/modules/onboarding/hooks/use-onboarding' ); vi.mock( 'src/hooks/use-site-details' ); +vi.mock( 'src/hooks/use-auth', () => ( { + useAuth: () => ( { + isAuthenticated: true, + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: undefined, + authenticate: vi.fn(), + logout: vi.fn(), + } ), +} ) ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/modules/whats-new/hooks/use-whats-new', () => ( { useWhatsNew: () => ( { showWhatsNew: false, @@ -62,6 +89,7 @@ vi.mock( 'src/lib/get-ipc-api', async () => { } ), getAllCustomDomains: vi.fn().mockResolvedValue( [] ), generateSiteNameFromList: vi.fn().mockResolvedValue( 'My WordPress Website' ), + isFullscreen: vi.fn().mockResolvedValue( false ), } ), }; } ); @@ -91,9 +119,42 @@ vi.mock( 'src/stores/wpcom-api', async () => { }; } ); +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + }; +} ); + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Remote Only', + url: 'https://remote-only.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + } ); +}; + describe( 'App', () => { beforeEach( () => { vi.clearAllMocks(); + featureFlagsMock.enableWorkspaces = false; + mockWpcomSitesQuery(); } ); const renderWithProvider = ( component: React.ReactElement ) => { @@ -112,7 +173,9 @@ describe( 'App', () => { } ); return render( - { component } + + { component } + ); }; @@ -135,4 +198,29 @@ describe( 'App', () => { expect( screen.getByText( 'Add a site' ) ).toBeInTheDocument(); } ); } ); + + it( 'renders workspace content for remote-only workspaces when enabled', async () => { + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite() ] ); + ( useOnboarding as Mock ).mockReturnValue( { + needsOnboarding: false, + } ); + ( useSiteDetails as Mock ).mockReturnValue( { + sites: [], + loadingSites: false, + selectedSite: null, + snapshots: [], + loadingServer: {}, + siteCreationMessages: {}, + setSelectedSiteId: vi.fn(), + } ); + + renderWithProvider( ); + + await waitFor( () => { + expect( screen.getByTestId( 'site-content' ) ).toBeInTheDocument(); + } ); + expect( screen.getAllByText( 'Remote Only' )[ 0 ] ).toBeVisible(); + expect( screen.queryByText( 'Add a site' ) ).not.toBeInTheDocument(); + } ); } ); diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index 4b525ef100..f753068ff3 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -1,13 +1,52 @@ -import { render, act, screen } from '@testing-library/react'; +import { configureStore } from '@reduxjs/toolkit'; +import { render, act, screen, waitFor } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import MainSidebar from 'src/components/main-sidebar'; import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; +import { + clearWorkspaceDollyAssistantStateCacheForTests, + getWorkspaceDollyConversationState, + writeWorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/session'; +import { startWorkspaceDollyTurn } from 'src/modules/workspaces/lib/dolly/turns'; +import { + WORKSPACE_DOLLY_AGENT_ID, + type WorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/types'; import { store } from 'src/stores'; +import { installedAppsApi } from 'src/stores/installed-apps-api'; +import { stagingSyncActions, stagingSyncThunks } from 'src/stores/sync'; +import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import type { SyncSite } from '@studio/common/types/sync'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const ipcApiMock = vi.hoisted( () => ( { + getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), + showOpenFolderDialog: vi.fn(), + generateProposedSitePath: vi.fn(), + openURL: vi.fn(), + getAllCustomDomains: vi.fn().mockResolvedValue( [] ), + getUserEditor: vi.fn().mockResolvedValue( 'cursor' ), + getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), + setWindowControlVisibility: vi.fn(), + setupAppMenu: vi.fn(), + addSyncOperation: vi.fn(), + clearSyncOperation: vi.fn(), +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/stores/wordpress-versions-api', () => ( { wordpressVersionsApi: { @@ -45,44 +84,94 @@ vi.mock( 'src/stores/wpcom-api', async () => { vi.mock( 'src/lib/get-ipc-api', () => ( { __esModule: true, default: vi.fn(), - getIpcApi: () => ( { - getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), - showOpenFolderDialog: vi.fn(), - generateProposedSitePath: vi.fn(), - openURL: vi.fn(), - getAllCustomDomains: vi.fn().mockResolvedValue( [] ), - getUserEditor: vi.fn().mockResolvedValue( 'cursor' ), - getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), - setWindowControlVisibility: vi.fn(), - setupAppMenu: vi.fn(), - } ), + getIpcApi: () => ipcApiMock, } ) ); -const site2 = { +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + }; +} ); + +store.replaceReducer( testReducer ); + +const createLocalSite = ( overrides: Partial< SiteDetails > = {} ): SiteDetails => + ( { + name: 'test-1', + path: '/fake/test-1', + running: false, + id: '0e9e237b-335a-43fa-b439-9b078a618512', + port: 8881, + phpVersion: '8.4', + ...overrides, + } ) as SiteDetails; + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Business Plan', + url: 'https://business.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const seedWorkspaceConversation = ( { + workspaceId, + conversationId, + message, + lastUpdated = Date.now(), +}: { + workspaceId: string; + conversationId: string; + message: string; + lastUpdated?: number; +} ) => { + writeWorkspaceDollyConversationState( { + id: conversationId, + key: { + workspaceId, + agentId: WORKSPACE_DOLLY_AGENT_ID, + }, + input: '', + messages: [ + { + id: 0, + role: 'user', + content: message, + createdAt: lastUpdated, + }, + ], + lastUpdated, + } as WorkspaceDollyConversationState ); +}; + +const site2 = createLocalSite( { name: 'test-2', path: '/fake/test-2', running: false, id: 'da1dad4b-37d5-41d2-a77b-26d5e0649ec3', port: 8882, -}; +} ); const siteDetailsMocked = { selectedSite: site2, sites: [ - { - name: 'test-1', - path: '/fake/test-1', - running: false, - id: '0e9e237b-335a-43fa-b439-9b078a618512', - port: 8881, - }, + createLocalSite(), site2, - { + createLocalSite( { name: 'test-3', path: '/fake/test-3', running: true, id: '0e9e237b-335a-43fa-b439-9b078a613333', port: 8883, - }, + } ), ], loadingServer: { [ site2.id ]: false, @@ -98,10 +187,53 @@ vi.mock( 'src/hooks/use-site-details', () => ( { useSiteDetails: () => ( { ...siteDetailsMocked } ), } ) ); -const renderWithProvider = ( children: React.ReactElement ) => { +const defaultLocalSites = () => [ + createLocalSite(), + site2, + createLocalSite( { + name: 'test-3', + path: '/fake/test-3', + running: true, + id: '0e9e237b-335a-43fa-b439-9b078a613333', + port: 8883, + } ), +]; + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + } ); +}; + +const enableWorkspaceSidebar = ( { + localSites = [], + wpcomSites = [], + connectedSites = [], +}: { + localSites?: SiteDetails[]; + wpcomSites?: SyncSite[]; + connectedSites?: SyncSite[]; +} ) => { + featureFlagsMock.enableWorkspaces = true; + siteDetailsMocked.sites = localSites; + siteDetailsMocked.selectedSite = localSites[ 0 ] ?? null; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { + isAuthenticated: true, + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: {} as never, + } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( connectedSites ); + mockWpcomSitesQuery( wpcomSites ); +}; + +const renderWithProvider = ( children: React.ReactElement, reduxStore = store ) => { return render( - - { children } + + + { children } + ); }; @@ -109,6 +241,15 @@ const renderWithProvider = ( children: React.ReactElement ) => { describe( 'MainSidebar Footer', () => { beforeEach( () => { vi.clearAllMocks(); + localStorage.clear(); + clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); + featureFlagsMock.enableWorkspaces = false; + siteDetailsMocked.sites = defaultLocalSites(); + siteDetailsMocked.selectedSite = site2; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); } ); it( 'Has add site button', async () => { vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); @@ -137,6 +278,19 @@ describe( 'MainSidebar Footer', () => { } ); describe( 'MainSidebar Site Menu', () => { + beforeEach( () => { + vi.clearAllMocks(); + localStorage.clear(); + clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); + featureFlagsMock.enableWorkspaces = false; + siteDetailsMocked.sites = defaultLocalSites(); + siteDetailsMocked.selectedSite = site2; + vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); + } ); + it( 'renders the list of sites', async () => { await act( async () => renderWithProvider( ) ); expect( screen.getByRole( 'button', { name: 'test-1' } ) ).toBeVisible(); @@ -172,3 +326,268 @@ describe( 'MainSidebar Site Menu', () => { ); } ); } ); + +describe( 'MainSidebar Workspace Site Menu', () => { + beforeEach( () => { + vi.clearAllMocks(); + localStorage.clear(); + clearWorkspaceDollyAssistantStateCacheForTests(); + store.dispatch( testActions.resetState() ); + store.dispatch( stagingSyncActions.clearStagingSyncState( { productionSiteId: 101 } ) ); + ipcApiMock.getConnectedWpcomSites.mockResolvedValue( [] ); + mockWpcomSitesQuery(); + } ); + + it( 'renders a local-only workspace', async () => { + const localSite = createLocalSite( { id: 'local-only', name: 'Local Only' } ); + enableWorkspaceSidebar( { localSites: [ localSite ] } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByRole( 'button', { name: 'Local Only' } ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'start Local Only site' } ) ).toBeVisible(); + } ); + + it( 'renders local and production targets as one workspace', async () => { + const localSite = createLocalSite( { id: 'business-local', name: 'Business Plan' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Business Plan', + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { name: 'Business Plan' } ); + + expect( screen.getAllByRole( 'button', { name: 'Business Plan' } ) ).toHaveLength( 1 ); + } ); + + it( 'renders production and staging targets as one remote-only workspace', async () => { + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Store', + url: 'https://remote.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Store Staging', + url: 'https://remote-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByRole( 'button', { name: 'Remote Store' } ) ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Remote Store Staging' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders local, production, and staging targets as one workspace', async () => { + const localSite = createLocalSite( { id: 'full-local', name: 'Full Workspace' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Full Workspace', + stagingSiteIds: [ 202 ], + syncSupport: 'already-connected', + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Full Workspace Staging', + url: 'https://full-staging.example', + isStaging: true, + productionSiteId: 101, + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + connectedSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { name: 'Full Workspace' } ); + + expect( screen.getAllByRole( 'button', { name: 'Full Workspace' } ) ).toHaveLength( 1 ); + } ); + + it( 'renders production-only remote workspaces in the workspace list', async () => { + const productionSite = createSyncSite( { + id: 303, + name: 'Remote Only', + url: 'https://remote-only.example', + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByRole( 'button', { name: 'Remote Only' } ) ).toBeVisible(); + } ); + + it( 'does not duplicate local-backed workspaces when connected metadata overlaps WP.com data', async () => { + const localSite = createLocalSite( { id: 'overlap-local', name: 'Overlap Site' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Overlap Site', + syncSupport: 'already-connected', + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite ], + connectedSites: [ productionSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await screen.findByRole( 'button', { name: 'Overlap Site' } ); + + expect( screen.getAllByRole( 'button', { name: 'Overlap Site' } ) ).toHaveLength( 1 ); + } ); + + it( 'does not render target indicators inside workspace rows', async () => { + const localSite = createLocalSite( { id: 'labels-local', name: 'Label Site' } ); + const productionSite = createSyncSite( { + id: 101, + localSiteId: localSite.id, + name: 'Label Site', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: localSite.id, + name: 'Label Site Staging', + url: 'https://label-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + localSites: [ localSite ], + wpcomSites: [ productionSite, stagingSite ], + } ); + + await act( async () => renderWithProvider( ) ); + await waitFor( () => + expect( screen.getByRole( 'button', { name: 'Label Site' } ) ).toBeVisible() + ); + + expect( screen.queryByLabelText( /Production target:/ ) ).not.toBeInTheDocument(); + expect( screen.queryByLabelText( /Staging target:/ ) ).not.toBeInTheDocument(); + expect( screen.queryByLabelText( /Local target:/ ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: /Staging target:/ } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: /Local target:/ } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'P' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'S' } ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'L' } ) ).not.toBeInTheDocument(); + } ); + + it( 'shows workspace activity when the assistant is thinking', async () => { + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Only', + url: 'https://remote-only.example', + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite ], + } ); + startWorkspaceDollyTurn( { + workspaceId: 'studio-workspace:wpcom:101', + conversationId: 'remote-only-chat', + abortController: new AbortController(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByLabelText( 'Remote Only assistant is thinking' ) ).toBeVisible(); + } ); + + it( 'shows workspace activity while a workspace sync is running', async () => { + const syncStore = configureStore( { + reducer: testReducer, + middleware: ( getDefaultMiddleware ) => + getDefaultMiddleware().concat( installedAppsApi.middleware ), + } ); + const productionSite = createSyncSite( { + id: 101, + name: 'Business Plan', + url: 'https://business-plan.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Business Plan Staging', + url: 'https://business-plan-staging.example', + isStaging: true, + productionSiteId: 101, + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite, stagingSite ], + } ); + syncStore.dispatch( + stagingSyncThunks.startStagingSiteSync.pending( 'request-id', { + productionSite, + stagingSite, + direction: 'push', + options: [ 'themes' ], + } ) + ); + expect( syncStore.getState().stagingSync.states[ 101 ] ).toMatchObject( { + status: 'started', + stagingSiteId: 202, + } ); + + await act( async () => renderWithProvider( , syncStore ) ); + + expect( await screen.findByRole( 'button', { name: 'Business Plan' } ) ).toBeVisible(); + expect( screen.getByLabelText( 'Business Plan sync is in progress' ) ).toBeVisible(); + } ); + + it( 'renders recent chats under their workspace and selects that workspace chat', async () => { + const user = userEvent.setup(); + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Only', + url: 'https://remote-only.example', + } ); + enableWorkspaceSidebar( { + wpcomSites: [ productionSite ], + } ); + seedWorkspaceConversation( { + workspaceId: 'studio-workspace:wpcom:101', + conversationId: 'workspace-chat-homepage', + message: 'Edit the homepage hero', + lastUpdated: Date.UTC( 2026, 4, 16 ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( await screen.findByRole( 'button', { name: 'Remote Only' } ) ).toBeVisible(); + const chatButton = screen.getByRole( 'button', { + name: 'Open chat: Edit the homepage hero', + } ); + expect( chatButton ).toBeVisible(); + + await user.click( chatButton ); + + expect( + getWorkspaceDollyConversationState( { + workspaceId: 'studio-workspace:wpcom:101', + remoteTargets: [], + } ).id + ).toBe( 'workspace-chat-homepage' ); + } ); +} ); diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index e2807e0f5d..07770e4903 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -1,11 +1,31 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { SiteContentTabs } from 'src/components/site-content-tabs'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { WorkspaceSelectionProvider } from 'src/modules/workspaces'; import { store } from 'src/stores'; +import { syncOperationsActions } from 'src/stores/sync'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview'; +import type { WorkspaceTargetId } from 'src/modules/workspaces/types'; + +const featureFlagsMock = vi.hoisted( () => ( { + enableBlueprints: true, + enableStudioCodeUi: false, + enableWorkspaces: false, +} ) ); +const useGetWpComSitesQueryMock = vi.hoisted( () => vi.fn() ); +const useGetActiveWpcomThemeQueryMock = vi.hoisted( () => vi.fn() ); +const useGetWpcomSiteSettingsQueryMock = vi.hoisted( () => vi.fn() ); +const syncHooksMock = vi.hoisted( () => ( { + useLatestRewindId: vi.fn(), + useRemoteFileTree: vi.fn(), +} ) ); const selectedSite: SiteDetails = { id: 'site-id-1', @@ -17,10 +37,50 @@ const selectedSite: SiteDetails = { }; vi.mock( 'src/hooks/use-site-details' ); +vi.mock( 'src/components/content-tab-assistant', () => ( { + ContentTabAssistant: ( { selectedSite }: { selectedSite: SiteDetails } ) => ( +
{ selectedSite.name }
+ ), +} ) ); +vi.mock( 'src/modules/workspaces/components/workspace-dolly-assistant', () => ( { + WorkspaceDollyAssistant: ( { + onOpenPreviewTarget, + previewState, + }: { + onOpenPreviewTarget: ( + targetId: WorkspaceTargetId, + pathOrUrl: string, + nextPreviewState: WorkspacePreviewState + ) => void; + previewState: WorkspacePreviewState; + } ) => ( +
+ +
+ ), +} ) ); +vi.mock( 'src/hooks/use-feature-flags', () => ( { + useFeatureFlags: () => featureFlagsMock, +} ) ); vi.mock( 'src/hooks/use-auth', () => ( { useAuth: () => ( { isAuthenticated: true, authenticate: vi.fn(), + user: { id: 123, email: 'user@example.com', displayName: 'User' }, + client: {} as never, } ), } ) ); vi.mock( 'src/lib/app-globals', async () => ( { @@ -35,6 +95,12 @@ vi.mock( 'src/lib/get-ipc-api', async () => ( { updateConnectedWpcomSites: vi.fn(), getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), getUserEditor: vi.fn().mockResolvedValue( 'vscode' ), + showErrorMessageBox: vi.fn(), + showMessageBox: vi.fn().mockResolvedValue( { response: 0 } ), + showNotification: vi.fn(), + connectWpcomSites: vi.fn().mockResolvedValue( undefined ), + openSiteURL: vi.fn(), + openURL: vi.fn(), setWindowControlVisibility: vi.fn(), } ), } ) ); @@ -65,27 +131,147 @@ vi.mock( 'src/stores/wpcom-api', async () => { }; } ); +vi.mock( 'src/stores/sync/wpcom-sites', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( + 'src/stores/sync/wpcom-sites' + ); + return { + ...actual, + useGetWpComSitesQuery: useGetWpComSitesQueryMock, + useGetActiveWpcomThemeQuery: useGetActiveWpcomThemeQueryMock, + useGetWpcomSiteSettingsQuery: useGetWpcomSiteSettingsQueryMock, + }; +} ); +vi.mock( 'src/stores/sync/sync-hooks', async () => { + const actual = await vi.importActual< typeof import('src/stores/sync/sync-hooks') >( + 'src/stores/sync/sync-hooks' + ); + return { + ...actual, + useLatestRewindId: syncHooksMock.useLatestRewindId, + useRemoteFileTree: syncHooksMock.useRemoteFileTree, + }; +} ); + store.replaceReducer( testReducer ); +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Remote Site', + url: 'https://remote.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const createSiteDetailsReturn = ( { + selectedSite: selectedSiteValue = selectedSite, + sites = [ selectedSite ], + setSelectedSiteId = vi.fn(), + startServer = vi.fn(), +}: { + selectedSite?: SiteDetails | null; + sites?: SiteDetails[]; + setSelectedSiteId?: ReturnType< typeof vi.fn >; + startServer?: ReturnType< typeof vi.fn >; +} = {} ) => + ( { + selectedSite: selectedSiteValue, + sites, + loadingServer: {}, + siteCreationMessages: {}, + setSelectedSiteId, + startServer, + stopServer: vi.fn(), + } ) as Partial< ReturnType< typeof useSiteDetails > >; + +const mockWpcomSitesQuery = ( sites: SyncSite[] = [] ) => { + useGetWpComSitesQueryMock.mockReturnValue( { + data: { sites, total: sites.length, page: 1, perPage: 100 }, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + } ); +}; + +const mockActiveWpcomThemeQuery = ( + data: + | { + id?: string; + name?: string; + screenshotUrl?: string; + isBlockTheme?: boolean; + } + | undefined = undefined +) => { + useGetActiveWpcomThemeQueryMock.mockReturnValue( { + data, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + } ); +}; + +const mockWpcomSiteSettingsQuery = ( + data: + | { + id?: number; + name?: string; + description?: string; + url?: string; + lang?: string; + localeVariant?: string; + settings: Record< string, unknown >; + } + | undefined = undefined +) => { + useGetWpcomSiteSettingsQueryMock.mockReturnValue( { + data, + isLoading: false, + isFetching: false, + refetch: vi.fn(), + } ); +}; + describe( 'SiteContentTabs', () => { beforeEach( () => { vi.clearAllMocks(); // Clear mock call history between tests + localStorage.clear(); + featureFlagsMock.enableWorkspaces = false; + mockWpcomSitesQuery(); + mockActiveWpcomThemeQuery(); + mockWpcomSiteSettingsQuery(); + syncHooksMock.useLatestRewindId.mockReturnValue( { + rewindId: null, + isLoading: false, + isError: false, + } ); + syncHooksMock.useRemoteFileTree.mockReturnValue( { + fetchChildren: vi.fn().mockResolvedValue( [] ), + isLoading: false, + error: null, + } ); store.dispatch( testActions.resetState() ); } ); const renderWithProvider = ( component: React.ReactElement ) => { return render( - { component } + + { component } + ); }; it( 'should render tabs correctly if selected site exists', async () => { vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { - selectedSite, - sites: [ selectedSite ], - loadingServer: {}, + ...createSiteDetailsReturn(), } ); await act( async () => renderWithProvider( ) ); + expect( screen.getByTestId( 'site-content-header' ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeInTheDocument(); @@ -96,9 +282,7 @@ describe( 'SiteContentTabs', () => { } ); it( 'selects the Overview tab by default', async () => { vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { - selectedSite, - sites: [ selectedSite ], - loadingServer: {}, + ...createSiteDetailsReturn(), } ); await act( async () => renderWithProvider( ) ); expect( screen.queryByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); @@ -110,4 +294,907 @@ describe( 'SiteContentTabs', () => { screen.queryByRole( 'tab', { name: 'Backup', selected: false } ) ).not.toBeInTheDocument(); } ); + + it( 'renders the shared workspace shell for a local target when enabled', async () => { + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByTestId( 'workspace-content-header' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toBeVisible(); + expect( + screen + .getByTestId( 'workspace-content-body' ) + .querySelector( '.workspace-content-shell__tabs--preview-controls-closed' ) + ).toHaveStyle( '--workspace-preview-controls-width: 520px' ); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( + 'Workspace site preview' + ) + ).not.toBeInTheDocument(); + } ); + + it( 'keeps local preview closed by default without starting the local site', async () => { + const startServer = vi.fn( () => Promise.resolve() ); + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { startServer } ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( startServer ).not.toHaveBeenCalled(); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( + 'Workspace site preview' + ) + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Show preview' } ) ).toBeVisible(); + } ); + + it( 'keeps the local workspace Assistant tab on the existing local assistant', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + + expect( screen.getByTestId( 'local-content-tab-assistant' ) ).toHaveTextContent( 'Test Site' ); + expect( screen.queryByTestId( 'workspace-dolly-assistant' ) ).not.toBeInTheDocument(); + } ); + + it( 'keeps linked workspace tabs stable and scopes content to the selected target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + name: 'Linked Workspace', + url: 'https://linked.example', + } ), + ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( screen.getByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Site Editor' } ) ).toBeVisible(); + expect( + screen.queryByText( 'This section is managed in the Local target.' ) + ).not.toBeInTheDocument(); + + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + + expect( screen.getByTestId( 'workspace-dolly-assistant' ) ).toBeInTheDocument(); + expect( screen.queryByTestId( 'local-content-tab-assistant' ) ).not.toBeInTheDocument(); + + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Local' } ) ); + + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( screen.getByTestId( 'local-content-tab-assistant' ) ).toHaveTextContent( 'Test Site' ); + expect( screen.queryByTestId( 'workspace-dolly-assistant' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders remote Production targets with the shared workspace tabs', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockActiveWpcomThemeQuery( { name: 'Remote Theme', isBlockTheme: true } ); + mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.getByTestId( 'workspace-content-header' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'tab', { name: 'Overview', selected: true } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toHaveClass( + 'components-tab-panel__tabs--assistant' + ); + expect( screen.getByRole( 'tab', { name: 'Assistant' } ) ).toHaveClass( 'ltr:ml-auto' ); + expect( screen.getByRole( 'tab', { name: 'Sync' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Previews' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Import / Export' } ) ).toBeVisible(); + expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); + expect( screen.getByText( 'Remote Theme' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Site Editor' } ) ).toBeVisible(); + expect( screen.queryByText( 'Open in…' ) ).not.toBeInTheDocument(); + + await user.click( screen.getByRole( 'button', { name: 'Site Editor' } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://remote.example/wp-admin/site-editor.php' + ); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).queryByLabelText( + 'Workspace site preview' + ) + ).not.toBeInTheDocument(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ).toBeVisible(); + } ); + + it( 'renders live site settings for the selected remote target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + name: 'Remote Only', + url: 'https://remote.example', + planName: 'Business', + wpVersion: '6.8', + canManageOptions: true, + } ), + ] ); + mockWpcomSiteSettingsQuery( { + id: 101, + name: 'Live Site Title', + description: 'Live site tagline', + url: 'https://remote.example', + lang: 'en', + settings: { + blog_public: 0, + show_on_front: 'page', + page_on_front: 42, + timezone_string: 'America/New_York', + jetpack_relatedposts_enabled: true, + }, + } ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Settings' } ) ); + + expect( screen.getByText( 'Live Site Title' ) ).toBeVisible(); + expect( screen.getByText( 'Live site tagline' ) ).toBeVisible(); + expect( screen.getByText( 'Discourage search engines' ) ).toBeVisible(); + expect( screen.getByText( 'Static page, page ID 42' ) ).toBeVisible(); + expect( screen.getByText( 'Business' ) ).toBeVisible(); + + await user.click( screen.getByRole( 'button', { name: 'Reading' } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://remote.example/wp-admin/options-reading.php' + ); + } ); + + it( 'opens remote preview under the workspace header', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + expect( + screen + .getByTestId( 'workspace-content-body' ) + .querySelector( '.workspace-content-shell__tabs--preview-controls-closed' ) + ).not.toBeInTheDocument(); + + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Reload preview', + } ) + ).toBeVisible(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Back', + } ) + ).toBeDisabled(); + expect( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Forward', + } ) + ).toBeDisabled(); + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + 'Workspace site preview' + ) + ).toBeVisible(); + const resizeHandle = within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( + 'separator', + { + name: 'Resize preview', + } + ); + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '520' ); + + resizeHandle.focus(); + await user.keyboard( '{ArrowLeft}' ); + + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '552' ); + } ); + + it( 'releases preview resizing even when dragging over the preview panel', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ createSyncSite( { id: 101, name: 'Remote Only' } ) ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + + const previewPanel = within( screen.getByTestId( 'workspace-content-body' ) ).getByLabelText( + 'Workspace site preview' + ); + vi.spyOn( previewPanel, 'getBoundingClientRect' ).mockReturnValue( { + x: 480, + y: 0, + width: 520, + height: 500, + top: 0, + right: 1000, + bottom: 500, + left: 480, + toJSON: () => ( {} ), + } ); + const resizeHandle = within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( + 'separator', + { + name: 'Resize preview', + } + ); + + fireEvent.mouseDown( resizeHandle, { clientX: 480 } ); + + const resizeOverlay = screen.getByTestId( 'workspace-preview-resize-overlay' ); + fireEvent.mouseMove( resizeOverlay, { clientX: 420 } ); + + expect( + within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( 'separator', { + name: 'Resize preview', + } ) + ).toHaveAttribute( 'aria-valuenow', '580' ); + + fireEvent.mouseUp( resizeOverlay ); + + expect( screen.queryByTestId( 'workspace-preview-resize-overlay' ) ).not.toBeInTheDocument(); + } ); + + it( 'switches preview targets inside the workspace shell without changing tabs', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( + within( screen.getByTestId( 'workspace-preview-controls' ) ).getByRole( 'button', { + name: 'Show preview', + } ) + ); + const resizeHandle = within( screen.getByTestId( 'workspace-content-body' ) ).getByRole( + 'separator', + { + name: 'Resize preview', + } + ); + resizeHandle.focus(); + await user.keyboard( '{ArrowLeft}' ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( screen.getByRole( 'tab', { name: 'Assistant', selected: true } ) ).toBeVisible(); + expect( screen.getByTitle( 'Remote Workspace preview' ) ).toHaveAttribute( + 'src', + 'https://production.example/' + ); + expect( resizeHandle ).toHaveAttribute( 'aria-valuenow', '552' ); + } ); + + it( 'rebases chat-updated preview URLs when switching targets', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Assistant' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Mock open staging preview' } ) ); + + await waitFor( () => + expect( screen.getByTitle( 'Remote Workspace Staging preview' ) ).toHaveAttribute( + 'src', + 'https://staging.example/wp-admin/' + ) + ); + + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + await waitFor( () => + expect( screen.getByTitle( 'Remote Workspace preview' ) ).toHaveAttribute( + 'src', + 'https://production.example/wp-admin/' + ) + ); + } ); + + it( 'renders a visible header target picker backed by the preview target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + name: 'Linked Workspace', + url: 'https://linked.example', + } ), + ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + + const headerTargetPicker = within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( + 'button', + { name: 'Workspace target' } + ); + expect( + screen.queryByRole( 'button', { name: /Select Local target:/ } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: /Select Production target:/ } ) + ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'button', { name: 'Preview target' } ) ).not.toBeInTheDocument(); + expect( headerTargetPicker ).toHaveTextContent( /Viewing\s*Local/ ); + expect( + screen.queryByLabelText( 'Local target: Test Site is stopped' ) + ).not.toBeInTheDocument(); + + await user.click( headerTargetPicker ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( headerTargetPicker ).toHaveTextContent( /Viewing\s*Production/ ); + await user.click( screen.getByRole( 'button', { name: 'Show preview' } ) ); + expect( screen.getByTitle( 'Linked Workspace preview' ) ).toHaveAttribute( + 'src', + 'https://linked.example/' + ); + } ); + + it( 'updates workspace header links for the selected live target', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + name: 'Remote Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + + const header = within( screen.getByTestId( 'workspace-content-header' ) ); + expect( header.getByRole( 'button', { name: /Staging WP admin/ } ) ).toBeVisible(); + expect( header.getByRole( 'button', { name: /Open staging site/ } ) ).toBeVisible(); + + await user.click( header.getByRole( 'button', { name: /Open staging site/ } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( 'https://staging.example/' ); + + await user.click( header.getByRole( 'button', { name: 'Workspace target' } ) ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( header.getByRole( 'button', { name: /Production WP admin/ } ) ).toBeVisible(); + expect( header.getByRole( 'button', { name: /Open production site/ } ) ).toBeVisible(); + + await user.click( header.getByRole( 'button', { name: /Production WP admin/ } ) ); + + expect( getIpcApi().openURL ).toHaveBeenLastCalledWith( + 'https://production.example/wp-admin/' + ); + } ); + + it( 'shows the local Start button only when the Local preview target is selected', async () => { + const user = userEvent.setup(); + const startServer = vi.fn( () => Promise.resolve() ); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + name: 'Linked Workspace', + url: 'https://linked.example', + } ), + ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + startServer, + } ), + } ); + + await act( async () => renderWithProvider( ) ); + + const startButton = within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( + 'button', + { + name: 'Start', + } + ); + expect( startButton ).toBeVisible(); + + await user.click( startButton ); + + expect( startServer ).toHaveBeenCalledWith( selectedSite ); + + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( + within( screen.getByTestId( 'workspace-content-header' ) ).queryByRole( 'button', { + name: 'Start', + } ) + ).not.toBeInTheDocument(); + } ); + + it( 'switches preview targets before opening the preview', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + mockWpcomSitesQuery( [ + createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + name: 'Linked Workspace', + url: 'https://linked.example', + } ), + ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( screen.queryByLabelText( 'Workspace site preview' ) ).not.toBeInTheDocument(); + + await user.click( + within( screen.getByTestId( 'workspace-content-header' ) ).getByRole( 'button', { + name: 'Workspace target', + } ) + ); + await user.click( screen.getByRole( 'option', { name: 'Production' } ) ); + + expect( screen.queryByLabelText( 'Workspace site preview' ) ).not.toBeInTheDocument(); + + await user.click( screen.getByRole( 'button', { name: 'Show preview' } ) ); + + expect( screen.getByTitle( 'Linked Workspace preview' ) ).toHaveAttribute( + 'src', + 'https://linked.example/' + ); + } ); + + it( 'omits missing target buttons from the shell', async () => { + featureFlagsMock.enableWorkspaces = true; + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn(), + } ); + + await act( async () => renderWithProvider( ) ); + + expect( + screen.queryByRole( 'button', { name: 'Production target unavailable' } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: 'Staging target unavailable' } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText( 'Local target: Test Site is stopped' ) + ).not.toBeInTheDocument(); + } ); + + it( 'renders workspace sync controls for local, production, and staging links', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByTestId( 'workspace-sync-panel' ) ).toBeVisible(); + expect( screen.getAllByText( 'Linked Workspace' ).length ).toBeGreaterThan( 0 ); + expect( screen.getByText( 'Linked Workspace Staging' ) ).toBeVisible(); + expect( screen.getByText( 'Production and staging' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Push to Staging' } ) ).toBeEnabled(); + expect( screen.getByRole( 'button', { name: 'Pull to Production' } ) ).toBeEnabled(); + + await user.click( screen.getByRole( 'button', { name: 'Pull to Production' } ) ); + + expect( screen.getByRole( 'heading', { name: 'Pull from Staging' } ) ).toBeVisible(); + expect( screen.getByRole( 'checkbox', { name: 'Files and folders' } ) ).not.toBeChecked(); + expect( screen.getByText( 'All files and folders' ) ).toBeVisible(); + expect( screen.getByRole( 'checkbox', { name: 'Database' } ) ).not.toBeChecked(); + expect( screen.queryByText( 'Root files' ) ).not.toBeInTheDocument(); + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeDisabled(); + + await user.click( screen.getByRole( 'checkbox', { name: 'Files and folders' } ) ); + + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeEnabled(); + } ); + + it( 'offers setup actions for a remote-only production workspace', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + hasStagingSiteFeature: true, + canManageOptions: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( + screen.queryByText( 'No workspace sync links are available yet.' ) + ).not.toBeInTheDocument(); + expect( screen.getAllByText( 'Remote Workspace' ).length ).toBeGreaterThan( 0 ); + expect( screen.getByRole( 'button', { name: 'Create local copy' } ) ).toBeVisible(); + expect( screen.getByText( 'Production and staging' ) ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Create staging site' } ) ).toBeVisible(); + } ); + + it( 'does not offer local copy setup when a remote site cannot support Studio sync', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + syncSupport: 'needs-upgrade', + hasStagingSiteFeature: true, + canManageOptions: true, + isWpcomAtomic: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( + screen.getByText( 'Upgrade this site plan before creating or connecting a local version.' ) + ).toBeVisible(); + expect( screen.queryByRole( 'button', { name: 'Create local copy' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: /Upgrade plan/ } ) ).toBeVisible(); + } ); + + it( 'does not offer staging creation when production is not eligible for staging sites', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + name: 'Remote Workspace', + url: 'https://remote-workspace.example', + syncSupport: 'syncable', + hasStagingSiteFeature: false, + canManageOptions: true, + isWpcomAtomic: true, + } ); + mockWpcomSitesQuery( [ productionSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { selectedSite: null, sites: [] } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByText( 'This site plan does not include staging sites.' ) ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Create staging site' } ) + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: /Upgrade plan/ } ) ).toBeVisible(); + } ); + + it( 'offers to connect an unlinked production target when local and staging are connected', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: '', + syncSupport: 'syncable', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getAllByText( 'Linked Workspace' ).length ).toBeGreaterThan( 0 ); + expect( + screen.getByText( 'Connect this site to the local site before syncing.' ) + ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Connect' } ) ).toBeEnabled(); + expect( screen.getByText( 'Linked Workspace Staging' ) ).toBeVisible(); + } ); + + it( 'lets Production/Staging sync select specific source files', async () => { + const user = userEvent.setup(); + const fetchChildren = vi.fn().mockResolvedValue( [ + { + id: 'plugin-path', + name: 'akismet', + label: 'akismet', + checked: false, + type: 'plugin', + pathId: 'cjI6,ZjI6YWtpc21ldC8=', + path: '/wp-content/plugins/akismet/', + }, + ] ); + syncHooksMock.useLatestRewindId.mockReturnValue( { + rewindId: '1234567890', + isLoading: false, + isError: false, + } ); + syncHooksMock.useRemoteFileTree.mockReturnValue( { + fetchChildren, + isLoading: false, + error: null, + } ); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Pull to Production' } ) ); + await user.selectOptions( + screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ), + 'specific' + ); + + const sourceFileCheckbox = await screen.findByRole( 'checkbox', { name: 'akismet' } ); + expect( fetchChildren ).toHaveBeenCalledWith( 202, '1234567890', '/wp-content/', false ); + expect( screen.getByRole( 'checkbox', { name: 'Database' } ) ).toBeDisabled(); + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeDisabled(); + + await user.click( sourceFileCheckbox ); + + expect( screen.getByTestId( 'environment-sync-submit-button' ) ).toBeEnabled(); + } ); + + it( 'disables Production/Staging sync while a local workspace sync is running', async () => { + const user = userEvent.setup(); + featureFlagsMock.enableWorkspaces = true; + const productionSite = createSyncSite( { + id: 101, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace', + url: 'https://production.example', + stagingSiteIds: [ 202 ], + } ); + const stagingSite = createSyncSite( { + id: 202, + localSiteId: selectedSite.id, + syncSupport: 'already-connected', + name: 'Linked Workspace Staging', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, + } ); + mockWpcomSitesQuery( [ productionSite, stagingSite ] ); + store.dispatch( + syncOperationsActions.updatePushState( { + selectedSiteId: selectedSite.id, + remoteSiteId: productionSite.id, + state: { + status: { + key: 'uploading', + progress: 40, + message: 'Uploading site...', + }, + selectedSite, + remoteSiteUrl: productionSite.url, + }, + } ) + ); + vi.mocked( useSiteDetails, { partial: true } ).mockReturnValue( { + ...createSiteDetailsReturn( { + selectedSite, + sites: [ selectedSite ], + } ), + } ); + + await act( async () => renderWithProvider( ) ); + await user.click( screen.getByRole( 'tab', { name: 'Sync' } ) ); + + expect( screen.getByRole( 'button', { name: 'Push to Staging' } ) ).toBeDisabled(); + expect( screen.getByRole( 'button', { name: 'Pull to Production' } ) ).toBeDisabled(); + } ); } ); diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index 5af5a82b6b..1ef194db94 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -262,4 +262,64 @@ describe( 'useAddSite', () => { } ); expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); } ); + + it( 'should create a local site from a remote site setup action', async () => { + const remoteSite: SyncSite = { + id: 123, + localSiteId: '', + name: 'Remote Site', + url: 'https://example.com', + isStaging: false, + isPressable: false, + environmentType: null, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + }; + const createdSite = { + id: 'local-id', + name: 'Remote Site', + path: '/default/path', + wpVersion: 'latest', + phpVersion: '8.4', + }; + + mockCreateSite.mockImplementation( + ( path, name, version, customDomain, enableHttps, blueprint, phpVersion, callback ) => { + callback( createdSite ); + return Promise.resolve( createdSite ); + } + ); + + const { result } = renderHookWithProvider( () => useAddSite() ); + + await act( async () => { + await result.current.createSiteFromRemoteSite( remoteSite ); + } ); + + expect( mockCreateSite ).toHaveBeenCalledWith( + '/default/path', + 'Remote Site', + 'latest', + undefined, + false, + undefined, + '8.4', + expect.any( Function ), + true + ); + expect( mockConnectWpcomSites ).toHaveBeenCalledWith( [ + { + sites: [ remoteSite ], + localSiteId: createdSite.id, + }, + ] ); + expect( mockPullSiteThunk ).toHaveBeenCalledWith( { + client: mockClient, + connectedSite: remoteSite, + selectedSite: createdSite, + options: { optionsToSync: [ 'all' ] }, + } ); + expect( mockSetSelectedTab ).toHaveBeenCalledWith( 'sync' ); + } ); } ); diff --git a/apps/studio/src/hooks/tests/use-site-details.test.tsx b/apps/studio/src/hooks/tests/use-site-details.test.tsx index 5bb8a7635a..e99b49ac6d 100644 --- a/apps/studio/src/hooks/tests/use-site-details.test.tsx +++ b/apps/studio/src/hooks/tests/use-site-details.test.tsx @@ -66,6 +66,7 @@ describe( 'useSiteDetails', () => { vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { getSiteDetails: vi.fn().mockResolvedValue( mockSites ), startServer: vi.fn( () => Promise.resolve() ), + stopServer: vi.fn( () => Promise.resolve() ), deleteSite: vi.fn( () => Promise.resolve() ), getConnectedWpcomSites: vi.fn( () => Promise.resolve( [] ) ), } ); @@ -168,6 +169,64 @@ describe( 'useSiteDetails', () => { } ); } ); + describe( 'server running state', () => { + it( 'marks a site as running after start succeeds', async () => { + const sites = mockSites.map( ( site ) => ( { + ...site, + autoStart: false, + } ) ); + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + getSiteDetails: vi.fn().mockResolvedValue( sites ), + startServer: vi.fn( () => Promise.resolve() ), + stopServer: vi.fn( () => Promise.resolve() ), + } ); + + const { result } = renderHook( () => useSiteDetails(), { wrapper } ); + + await waitFor( () => { + expect( result.current.loadingSites ).toBe( false ); + } ); + + await act( async () => { + await result.current.startServer( sites[ 1 ] ); + } ); + + const startedSite = result.current.sites.find( ( site ) => site.id === 'site-2' ); + expect( startedSite?.running ).toBe( true ); + if ( startedSite?.running ) { + expect( startedSite.url ).toBe( 'http://localhost:1235' ); + } + } ); + + it( 'marks a site as stopped after stop succeeds', async () => { + const runningSite: StartedSiteDetails = { + ...mockSites[ 1 ], + autoStart: false, + running: true, + url: 'http://localhost:1235', + }; + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + getSiteDetails: vi.fn().mockResolvedValue( [ runningSite ] ), + startServer: vi.fn( () => Promise.resolve() ), + stopServer: vi.fn( () => Promise.resolve() ), + } ); + + const { result } = renderHook( () => useSiteDetails(), { wrapper } ); + + await waitFor( () => { + expect( result.current.loadingSites ).toBe( false ); + } ); + + await act( async () => { + await result.current.stopServer( runningSite.id ); + } ); + + const stoppedSite = result.current.sites.find( ( site ) => site.id === runningSite.id ); + expect( stoppedSite?.running ).toBe( false ); + expect( stoppedSite ).not.toHaveProperty( 'url' ); + } ); + } ); + describe( 'startServer error handling', () => { function setupStartServerError( error: Error ) { const showErrorMessageBox = vi.fn(); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 5dc79b4f06..831b61ca8d 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -10,6 +10,7 @@ import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useFindAvailableSiteName } from 'src/modules/add-site/hooks/use-find-available-site-name'; import { useAppDispatch } from 'src/stores'; import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; @@ -53,6 +54,7 @@ export function useAddSite() { const { client } = useAuth(); const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); + const findAvailableSiteName = useFindAvailableSiteName(); const [ fileForImport, setFileForImport ] = useState< File | null >( null ); const [ selectedBlueprint, setSelectedBlueprint ] = useState< Blueprint | undefined >(); const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | undefined >(); @@ -315,9 +317,60 @@ export function useAddSite() { ] ); + const createSiteFromRemoteSite = useCallback( + async ( remoteSite: SyncSite ) => { + const siteName = await findAvailableSiteName( remoteSite.name ); + const proposedPath = await generateProposedPath( siteName ); + if ( proposedPath.error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create local site' ), + message: proposedPath.error, + } ); + return; + } + + return createSite( + proposedPath.path, + siteName, + DEFAULT_WORDPRESS_VERSION, + undefined, + false, + undefined, + DEFAULT_PHP_VERSION, + async ( newSite ) => { + await connectSite( { site: remoteSite, localSiteId: newSite.id } ); + if ( client ) { + const pullOptions: SyncOption[] = [ 'all' ]; + void dispatch( + syncOperationsThunks.pullSite( { + client, + connectedSite: remoteSite, + selectedSite: newSite, + options: { optionsToSync: pullOptions }, + } ) + ); + setSelectedTab( 'sync' ); + } + }, + true + ); + }, + [ + __, + client, + connectSite, + createSite, + dispatch, + findAvailableSiteName, + generateProposedPath, + setSelectedTab, + ] + ); + return useMemo( () => ( { handleCreateSite, + createSiteFromRemoteSite, selectPath, generateProposedPath, deeplinkPhpVersion, @@ -350,6 +403,7 @@ export function useAddSite() { } ), [ handleCreateSite, + createSiteFromRemoteSite, selectPath, generateProposedPath, deeplinkPhpVersion, diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index f34e3748a5..03a3524530 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -114,6 +114,33 @@ function useSelectedSite( firstSiteId: string | null ) { }; } +function resolveSiteUrl( site: SiteDetails ) { + if ( site.customDomain ) { + const protocol = site.enableHttps ? 'https' : 'http'; + return `${ protocol }://${ site.customDomain }`; + } + + return `http://localhost:${ site.port }`; +} + +function setSiteRunningState( site: SiteDetails, running: boolean ): SiteDetails { + if ( running ) { + return { + ...site, + running: true, + url: resolveSiteUrl( site ), + }; + } + + const stoppedSite = { + ...site, + running: false, + }; + delete ( stoppedSite as Partial< StartedSiteDetails > ).url; + + return stoppedSite as SiteDetails; +} + export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { const { Provider } = siteDetailsContext; @@ -157,13 +184,15 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { } if ( ! site ) { + if ( eventType === SITE_EVENTS.UPDATED ) { + return prevSites.map( ( prevSite ) => + prevSite.id === siteId ? setSiteRunningState( prevSite, running ) : prevSite + ); + } return prevSites; } - const siteDetails: SiteDetails = { - ...site, - running, - }; + const siteDetails = setSiteRunningState( { ...site, running } as SiteDetails, running ); const existingIndex = prevSites.findIndex( ( s ) => s.id === siteId ); @@ -404,6 +433,11 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { try { await getIpcApi().startServer( id ); + setSites( ( prevSites ) => + prevSites.map( ( prevSite ) => + prevSite.id === id ? setSiteRunningState( prevSite, true ) : prevSite + ) + ); } catch ( error ) { if ( error instanceof Error && error.message.includes( 'PROXY_ERROR_PORT_IN_USE' ) ) { getIpcApi().showErrorMessageBox( { @@ -577,6 +611,11 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { async ( id: string ) => { toggleLoadingServerForSite( id ); await getIpcApi().stopServer( id ); + setSites( ( prevSites ) => + prevSites.map( ( prevSite ) => + prevSite.id === id ? setSiteRunningState( prevSite, false ) : prevSite + ) + ); toggleLoadingServerForSite( id ); }, [ toggleLoadingServerForSite ] diff --git a/apps/studio/src/index.css b/apps/studio/src/index.css index fdf277c36c..bbe7023f5a 100644 --- a/apps/studio/src/index.css +++ b/apps/studio/src/index.css @@ -130,6 +130,10 @@ blockquote { border-bottom: 1px solid var( --color-frame-border ); } +.workspace-content-shell__tabs--preview-controls-closed .components-tab-panel__tabs { + padding-inline-end: calc( var( --workspace-preview-controls-width, 0px ) + 1rem ); +} + .components-tab-panel__tabs-item { height: 40px; margin-bottom: -1px; diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index e7bc8becfb..d4f2d8fa4e 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -305,7 +305,7 @@ async function appBoot() { const basePolicies = [ "default-src 'self'", // Allow resources from these domains "script-src-attr 'none'", - "img-src 'self' https://*.gravatar.com https://*.wp.com https://blueprintlibrary.wordpress.com https://blueprintslibraryv2.wpcomstaging.com https://wordpress.github.io https://raw.githubusercontent.com data:", + "img-src 'self' https: data: blob:", "style-src 'self' 'unsafe-inline'", // unsafe-inline used by tailwindcss in development, and also in production after the app rename "script-src 'self' 'wasm-unsafe-eval'", // allow WebAssembly to compile and instantiate // Site preview uses `` to host local WordPress sites diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index ca9dea6b4a..cddf22a710 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -40,6 +40,7 @@ import { } from '@studio/common/lib/blueprint-bundle'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites'; import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; import { extractZip } from '@studio/common/lib/extract-zip'; @@ -77,7 +78,7 @@ import { SIDEBAR_WIDTH, WINDOWS_TITLEBAR_HEIGHT, } from 'src/constants'; -import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; +import { sendIpcEventToRenderer, sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { deleteAiSessionPlacement, hydrateAiSessionSummaryWithPlacement, @@ -1029,6 +1030,24 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom ); console.log( `Server started for '${ server.details.name }'` ); + await sendIpcEventToRenderer( 'site-event', { + event: SITE_EVENTS.UPDATED, + siteId: id, + running: server.details.running, + site: { + ...server.details, + url: getSiteEventUrl( server.details ), + }, + } ); +} + +function getSiteEventUrl( site: SiteDetails ) { + if ( site.customDomain ) { + const protocol = site.enableHttps ? 'https' : 'http'; + return `${ protocol }://${ site.customDomain }`; + } + + return `http://localhost:${ site.port }`; } export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { @@ -1038,6 +1057,15 @@ export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promi } await server.stop(); + await sendIpcEventToRenderer( 'site-event', { + event: SITE_EVENTS.UPDATED, + siteId: id, + running: server.details.running, + site: { + ...server.details, + url: getSiteEventUrl( server.details ), + }, + } ); } export async function stopAllServers(): Promise< void > { diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index f57b868f55..8f831bd85e 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -100,6 +100,7 @@ type IpcApi = { interface FeatureFlags { enableBlueprints: boolean; enableStudioCodeUi: boolean; + enableWorkspaces: boolean; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/apps/studio/src/lib/feature-flags.ts b/apps/studio/src/lib/feature-flags.ts index a1bd010661..6b870ba5ae 100644 --- a/apps/studio/src/lib/feature-flags.ts +++ b/apps/studio/src/lib/feature-flags.ts @@ -18,6 +18,12 @@ export const FEATURE_FLAGS: Record< keyof FeatureFlags, FeatureFlagDefinition > flag: 'enableStudioCodeUi', default: false, }, + enableWorkspaces: { + label: 'Workspaces', + env: 'ENABLE_WORKSPACES', + flag: 'enableWorkspaces', + default: false, + }, } as const; export function getFeatureFlagFromEnv( flag: keyof FeatureFlags ): boolean { diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 8767034f80..af4a7e18e5 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -3,7 +3,7 @@ import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; import { cloudUpload, cloudDownload, info, close, error } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import { ClearAction } from 'src/components/clear-action'; @@ -48,12 +48,16 @@ import { } from 'src/stores/sync/connected-sites'; import type { SyncSite } from '@studio/common/types/sync'; -const SyncConnectedSiteControls = ( { +export const SyncConnectedSiteControls = ( { connectedSite, selectedSite, + disabled = false, + disabledReason, }: { connectedSite: SyncSite; selectedSite: SiteDetails; + disabled?: boolean; + disabledReason?: string; } ) => { const { __ } = useI18n(); const isOffline = useOffline(); @@ -75,6 +79,10 @@ const SyncConnectedSiteControls = ( { ) ); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + const isSyncActionDisabled = isAnySiteSyncing || disabled; + const syncDisabledText = disabled + ? disabledReason ?? __( 'This sync action is temporarily unavailable.' ) + : undefined; return (
- { isAnySiteSyncing ? ( + { isSyncActionDisabled ? ( @@ -117,7 +126,7 @@ const SyncConnectedSiteControls = ( { '!text-frame-text hover:!text-frame-theme' ) } onClick={ () => setSyncDialogType( 'pull' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isSyncActionDisabled || isOffline } data-testid="sync-list-pull-button" > @@ -125,16 +134,17 @@ const SyncConnectedSiteControls = ( { ) } - { isAnySiteSyncing ? ( + { isSyncActionDisabled ? ( @@ -158,7 +168,7 @@ const SyncConnectedSiteControls = ( { '!text-frame-text hover:!text-frame-theme' ) } onClick={ () => setSyncDialogType( 'push' ) } - disabled={ isAnySiteSyncing || isOffline } + disabled={ isSyncActionDisabled || isOffline } data-testid="sync-list-push-button" > @@ -765,13 +775,17 @@ export function SyncConnectedSites( { connectedSites, disconnectSite, selectedSite, + children, + className, }: { connectedSites: SyncSite[]; disconnectSite: ( id: number ) => void; selectedSite: SiteDetails; + children?: ReactNode; + className?: string; } ) { return ( -
+
{ connectedSites.map( ( connectedSite ) => ( ) ) } + { children }
); } diff --git a/apps/studio/src/modules/sync/components/sync-dialog.tsx b/apps/studio/src/modules/sync/components/sync-dialog.tsx index ff35c0973d..b77a8b974d 100644 --- a/apps/studio/src/modules/sync/components/sync-dialog.tsx +++ b/apps/studio/src/modules/sync/components/sync-dialog.tsx @@ -1,11 +1,6 @@ import { PRESSABLE_PHP_VERSION } from '@studio/common/constants'; import { SYNC_PUSH_SIZE_LIMIT_GB } from '@studio/common/lib/sync/constants'; -import { - Icon, - SelectControl, - Notice, - __experimentalHeading as Heading, -} from '@wordpress/components'; +import { Icon, Notice } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { sprintf, __ } from '@wordpress/i18n'; import { cautionFilled } from '@wordpress/icons'; @@ -14,8 +9,6 @@ import { format } from 'date-fns'; import { useState, useEffect, useCallback } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; -import { RightArrowIcon } from 'src/components/icons/right-arrow'; -import Modal from 'src/components/modal'; import { TwoColorProgressBar } from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { TreeView, TreeNode, updateNodeById } from 'src/components/tree-view'; @@ -25,7 +18,8 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; import { hasVersionMismatch } from 'src/modules/preview-site/lib/version-comparison'; -import { SiteNameBox } from 'src/modules/sync/components/site-name-box'; +import { SyncFilesSelectControl } from 'src/modules/sync/components/sync-files-select-control'; +import { SyncModalShell } from 'src/modules/sync/components/sync-modal-shell'; import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size'; import { useSyncDialogTexts } from 'src/modules/sync/hooks/use-sync-dialog-texts'; import { useTopLevelSyncTree } from 'src/modules/sync/hooks/use-top-level-sync-tree'; @@ -224,20 +218,13 @@ export function SyncDialog( { ).length; const [ warningsExpanded, setWarningsExpanded ] = useState( true ); - const localSiteName = ; - const remoteSiteName = ; - - let syncFrom, syncTo, syncFromText, syncToText, tooltipNoRewindId; + let sourceSite, destinationSite, tooltipNoRewindId; if ( type === 'push' ) { - syncFrom = localSiteName; - syncTo = remoteSiteName; - syncFromText = localSite.name; - syncToText = remoteSite.name; + sourceSite = { name: localSite.name, envType: 'studio' as const }; + destinationSite = { name: remoteSite.name, envType: siteEnv }; } else { - syncFrom = remoteSiteName; - syncTo = localSiteName; - syncFromText = remoteSite.name; - syncToText = localSite.name; + sourceSite = { name: remoteSite.name, envType: siteEnv }; + destinationSite = { name: localSite.name, envType: 'studio' as const }; tooltipNoRewindId = createInterpolateElement( __( 'Selecting individual items to pull will be enabled automatically once your first backup is complete.
Wait a few minutes or run a full sync in the meantime.' @@ -320,140 +307,16 @@ export function SyncDialog( { }; return ( - -
-
{ syncTexts.description }
-
- - { /* translators: first %s is the source site name, second %s is the destination site name */ } - { sprintf( __( 'From %s to %s' ), syncFromText, syncToText ) } - - -
- - { syncTexts.subtitleSelector } - - -
- { type === 'pull' && isLoadingRewindId && } - { type === 'push' && isLoadingLocalFileTree && } - { ! isLoadingRewindId && ! isLoadingLocalFileTree && ( - <> -
- handleExpanderChange( value === 'true' ) } - disabled={ isErrorRewindId } - __next40pxDefaultSize - __nextHasNoMarginBottom - aria-label={ __( 'Select files and folders to sync' ) } - className="h-9 select-minimal" - /> -
- { - if ( nodeId === 'filesAndFolders' && showAllFiles && rewindId ) { - const backupUrl = `https://wordpress.com/backup/${ remoteSite.url.replace( - /^https?:\/\//, - '' - ) }`; - const backupDate = format( parseInt( rewindId ) * 1000, 'MMM d, y, h:mm a' ); - return ( -
- { sprintf( __( 'Content from the latest backup: %s.' ), backupDate ) }{ ' ' } - -
- ); - } - return null; - } } - renderEmptyContent={ ( nodeId, node ) => { - if ( nodeId === 'wp-content' && type === 'push' && localFileTreeError ) { - return ( -
- { __( - 'Could not load files. Please close and reopen this dialog to try again.' - ) } -
- ); - } - if ( - ( nodeId === 'wp-content' && type === 'pull' && remoteFileTreeError ) || - node.hasError - ) { - return ( -
- { __( - 'Error retrieving remote files and directories. Please close and reopen this dialog to try again.' - ) } -
- ); - } - return ( -
- { __( 'Empty' ) } -
- ); - } } - /> - - ) } -
-
- -
+ description={ syncTexts.description } + subtitle={ syncTexts.subtitleSelector } + source={ sourceSite } + destination={ destinationSite } + onRequestClose={ onRequestClose } + contentStyle={ { paddingBottom: getBottomPadding() } } + footer={ + <> { type === 'push' && (
+ + } + > + +
+ { type === 'pull' && isLoadingRewindId && } + { type === 'push' && isLoadingLocalFileTree && } + { ! isLoadingRewindId && ! isLoadingLocalFileTree && ( + <> +
+ handleExpanderChange( value === 'specific' ) } + disabled={ isErrorRewindId } + /> +
+ { + if ( nodeId === 'filesAndFolders' && showAllFiles && rewindId ) { + const backupUrl = `https://wordpress.com/backup/${ remoteSite.url.replace( + /^https?:\/\//, + '' + ) }`; + const backupDate = format( parseInt( rewindId ) * 1000, 'MMM d, y, h:mm a' ); + return ( +
+ { sprintf( __( 'Content from the latest backup: %s.' ), backupDate ) }{ ' ' } + +
+ ); + } + return null; + } } + renderEmptyContent={ ( nodeId, node ) => { + if ( nodeId === 'wp-content' && type === 'push' && localFileTreeError ) { + return ( +
+ { __( + 'Could not load files. Please close and reopen this dialog to try again.' + ) } +
+ ); + } + if ( + ( nodeId === 'wp-content' && type === 'pull' && remoteFileTreeError ) || + node.hasError + ) { + return ( +
+ { __( + 'Error retrieving remote files and directories. Please close and reopen this dialog to try again.' + ) } +
+ ); + } + return ( +
+ { __( 'Empty' ) } +
+ ); + } } + /> + + ) }
-
-
+ + ); } diff --git a/apps/studio/src/modules/sync/components/sync-files-select-control.tsx b/apps/studio/src/modules/sync/components/sync-files-select-control.tsx new file mode 100644 index 0000000000..28cd4474c7 --- /dev/null +++ b/apps/studio/src/modules/sync/components/sync-files-select-control.tsx @@ -0,0 +1,45 @@ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export type SyncFilesSelectionMode = 'all' | 'specific'; + +type SyncFilesSelectControlProps = { + value: SyncFilesSelectionMode; + onChange: ( value: SyncFilesSelectionMode ) => void; + disabled?: boolean; + showSpecificOption?: boolean; +}; + +export function SyncFilesSelectControl( { + value, + onChange, + disabled, + showSpecificOption = true, +}: SyncFilesSelectControlProps ) { + return ( + onChange( nextValue as SyncFilesSelectionMode ) } + disabled={ disabled } + __next40pxDefaultSize + __nextHasNoMarginBottom + aria-label={ __( 'Select files and folders to sync' ) } + className="h-9 select-minimal" + /> + ); +} diff --git a/apps/studio/src/modules/sync/components/sync-modal-shell.tsx b/apps/studio/src/modules/sync/components/sync-modal-shell.tsx new file mode 100644 index 0000000000..59167750f0 --- /dev/null +++ b/apps/studio/src/modules/sync/components/sync-modal-shell.tsx @@ -0,0 +1,88 @@ +import { __experimentalHeading as Heading } from '@wordpress/components'; +import { sprintf, __ } from '@wordpress/i18n'; +import { type CSSProperties, type ReactNode } from 'react'; +import { RightArrowIcon } from 'src/components/icons/right-arrow'; +import Modal from 'src/components/modal'; +import { SiteNameBox } from 'src/modules/sync/components/site-name-box'; +import type { EnvironmentType } from 'src/modules/sync/lib/environment-utils'; + +type SyncModalSite = { + name: string; + envType: EnvironmentType | 'studio'; +}; + +type SyncModalShellProps = { + title: string; + description: string; + subtitle: string; + source: SyncModalSite; + destination: SyncModalSite; + onRequestClose: () => void; + children: ReactNode; + footer: ReactNode; + contentClassName?: string; + contentStyle?: CSSProperties; +}; + +export function SyncModalShell( { + title, + description, + subtitle, + source, + destination, + onRequestClose, + children, + footer, + contentClassName, + contentStyle, +}: SyncModalShellProps ) { + return ( + +
+
{ description }
+
+ + { /* translators: first %s is the source site name, second %s is the destination site name */ } + { sprintf( __( 'From %s to %s' ), source.name, destination.name ) } + + +
+ + { subtitle } + + { children } +
+ { footer } +
+
+
+ ); +} diff --git a/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx new file mode 100644 index 0000000000..025f9d5947 --- /dev/null +++ b/apps/studio/src/modules/sync/components/workspace-sync-panel.tsx @@ -0,0 +1,977 @@ +import { CheckboxControl, Icon, Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { cloudDownload, cloudUpload } from '@wordpress/icons'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { Tooltip } from 'src/components/tooltip'; +import { TreeView, updateNodeById, type TreeNode } from 'src/components/tree-view'; +import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; +import { useAddSite } from 'src/hooks/use-add-site'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { getLocalizedLink } from 'src/lib/get-localized-link'; +import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge'; +import { SyncConnectedSites } from 'src/modules/sync/components/sync-connected-sites'; +import { + SyncFilesSelectControl, + type SyncFilesSelectionMode, +} from 'src/modules/sync/components/sync-files-select-control'; +import { SyncModalShell } from 'src/modules/sync/components/sync-modal-shell'; +import { TreeViewLoadingSkeleton } from 'src/modules/sync/components/tree-view-loading-skeleton'; +import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; +import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; +import { + stagingSyncSelectors, + stagingSyncThunks, + syncOperationsSelectors, + type StagingSyncDirection, + type StagingSyncOption, + type StagingSyncOptions, +} from 'src/stores/sync'; +import { useConnectSiteMutation, useDisconnectSiteMutation } from 'src/stores/sync/connected-sites'; +import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync/sync-hooks'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types'; + +type WorkspaceSyncPanelContentProps = { + workspace: StudioWorkspace; + selectedTargetId?: WorkspaceTargetId; +}; + +type EnvironmentSyncDialogProps = { + direction: StagingSyncDirection; + productionSite: SyncSite; + stagingSite: SyncSite; + onClose: () => void; +}; + +const ENVIRONMENT_FILE_SYNC_OPTIONS: StagingSyncOption[] = [ + 'contents', + 'themes', + 'plugins', + 'uploads', + 'roots', +]; + +const createEnvironmentFileTree = (): TreeNode[] => [ + { + id: 'wp-content', + name: 'wp-content', + label: 'wp-content', + checked: false, + indeterminate: false, + type: 'folder', + children: [], + expanded: true, + }, +]; + +function collectSelectedPathIds( nodes: TreeNode[] | undefined ): string[] { + if ( ! nodes?.length ) { + return []; + } + + return nodes.flatMap( ( node ) => { + const selectedPathIds: string[] = []; + if ( node.checked && node.pathId ) { + selectedPathIds.push( node.pathId ); + } + if ( node.children?.length && ( node.checked || node.indeterminate ) ) { + selectedPathIds.push( ...collectSelectedPathIds( node.children ) ); + } + return selectedPathIds; + } ); +} + +function isRemoteConnectedToLocal( remoteSite: SyncSite | undefined, localSite?: SiteDetails ) { + return Boolean( + remoteSite && + localSite && + remoteSite.localSiteId === localSite.id && + remoteSite.syncSupport === 'already-connected' + ); +} + +function getRemoteSiteLocalSetupState( remoteSite: SyncSite ) { + switch ( remoteSite.syncSupport ) { + case 'syncable': + return { + canUseLocalSetup: true, + }; + case 'needs-upgrade': + return { + canUseLocalSetup: false, + description: __( 'Upgrade this site plan before creating or connecting a local version.' ), + buttonLabel: __( 'Upgrade plan' ), + actionUrl: `https://wordpress.com/plans/${ remoteSite.id }`, + }; + case 'needs-transfer': + return { + canUseLocalSetup: false, + description: __( 'Enable hosting features before creating or connecting a local version.' ), + buttonLabel: __( 'Enable hosting' ), + actionUrl: `https://wordpress.com/hosting-features/${ remoteSite.id }`, + }; + case 'missing-permissions': + return { + canUseLocalSetup: false, + description: __( + 'You need permission to manage this site before creating or connecting a local version.' + ), + buttonLabel: __( 'Missing permissions' ), + }; + case 'deleted': + return { + canUseLocalSetup: false, + description: __( 'This WordPress.com site is deleted.' ), + buttonLabel: __( 'Deleted' ), + }; + case 'unsupported': + return { + canUseLocalSetup: false, + description: __( 'This site does not support Studio sync.' ), + buttonLabel: __( 'Unsupported' ), + }; + case 'already-connected': + return { + canUseLocalSetup: false, + description: __( 'This target is already connected to another local site.' ), + buttonLabel: __( 'Already connected' ), + }; + } +} + +function getProductionStagingSetupState( productionSite: SyncSite ) { + if ( productionSite.canManageOptions === false ) { + return { + canCreateStagingSite: false, + description: __( + 'You need permission to manage this production site before creating staging.' + ), + buttonLabel: __( 'Missing permissions' ), + }; + } + + if ( productionSite.hasStagingSiteFeature === false ) { + return { + canCreateStagingSite: false, + description: __( 'This site plan does not include staging sites.' ), + buttonLabel: __( 'Upgrade plan' ), + actionUrl: `https://wordpress.com/plans/${ productionSite.id }`, + }; + } + + if ( productionSite.isWpcomAtomic === false ) { + return { + canCreateStagingSite: false, + description: __( 'Enable hosting features before creating a staging site.' ), + buttonLabel: __( 'Enable hosting' ), + actionUrl: `https://wordpress.com/hosting-features/${ productionSite.id }`, + }; + } + + return { + canCreateStagingSite: true, + description: __( 'Create a live staging site from Production.' ), + buttonLabel: __( 'Create staging site' ), + }; +} + +function useLocalRemoteSyncState( localSite?: SiteDetails, remoteSite?: SyncSite ) { + const isPulling = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPulling( localSite?.id ?? '', remoteSite?.id ) + ); + const isPushing = useRootSelector( + syncOperationsSelectors.selectIsSiteIdPushing( localSite?.id ?? '', remoteSite?.id ) + ); + + return { + isSyncing: isPulling || isPushing, + }; +} + +function EnvironmentSyncDialog( { + direction, + productionSite, + stagingSite, + onClose, +}: EnvironmentSyncDialogProps ) { + const locale = useI18nLocale(); + const dispatch = useAppDispatch(); + const [ includeFiles, setIncludeFiles ] = useState( false ); + const [ includeDatabase, setIncludeDatabase ] = useState( false ); + const [ fileSelectionMode, setFileSelectionMode ] = useState< SyncFilesSelectionMode >( 'all' ); + const [ fileTree, setFileTree ] = useState< TreeNode[] >( () => createEnvironmentFileTree() ); + const [ fileTreeError, setFileTreeError ] = useState< Error | null >( null ); + const [ isLoadingFileTree, setIsLoadingFileTree ] = useState( false ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + const isPull = direction === 'pull'; + const title = isPull ? __( 'Pull from Staging' ) : __( 'Push to Staging' ); + const actionLabel = isPull ? __( 'Pull' ) : __( 'Push' ); + const sourceSite = isPull ? stagingSite : productionSite; + const destinationSite = isPull ? productionSite : stagingSite; + const isUsingSpecificFileSelection = includeFiles && fileSelectionMode === 'specific'; + const selectedPathIds = useMemo( () => collectSelectedPathIds( fileTree ), [ fileTree ] ); + const selectedOptions: StagingSyncOptions = isUsingSpecificFileSelection + ? { + types: 'paths', + include_paths: selectedPathIds, + exclude_paths: [], + } + : [ + ...( includeFiles ? ENVIRONMENT_FILE_SYNC_OPTIONS : [] ), + ...( includeDatabase ? ( [ 'sqls' ] as const ) : [] ), + ]; + const hasSelectedOptions = Array.isArray( selectedOptions ) + ? selectedOptions.length > 0 + : selectedOptions.include_paths.length > 0; + const syncDescription = isPull + ? __( + "Pulling will overwrite your production site's selected files and database with a copy from your staging site. Unchecked items will not be changed." + ) + : __( + "Pushing will overwrite your staging site's selected files and database with a copy from your production site. Unchecked items will not be changed." + ); + const subtitle = isPull + ? __( 'What would you like to pull?' ) + : __( 'What would you like to push?' ); + const docsLabel = isPull + ? __( 'Read more about environment pull' ) + : __( 'Read more about environment push' ); + const sourceEnvironment = isPull ? 'staging' : 'production'; + const destinationEnvironment = isPull ? 'production' : 'staging'; + const { + rewindId, + isLoading: isLoadingRewindId, + isError: isErrorRewindId, + } = useLatestRewindId( sourceSite.id, { + skip: ! isUsingSpecificFileSelection, + } ); + const { fetchChildren } = useRemoteFileTree(); + const isSubmitDisabled = + ! hasSelectedOptions || isSubmitting || isLoadingRewindId || isLoadingFileTree; + + useEffect( () => { + setFileTree( createEnvironmentFileTree() ); + setFileTreeError( null ); + }, [ sourceSite.id ] ); + + useEffect( () => { + if ( ! isUsingSpecificFileSelection || ! rewindId ) { + return; + } + + let isCancelled = false; + const loadSourceFileTree = async () => { + setIsLoadingFileTree( true ); + setFileTreeError( null ); + try { + const children = await fetchChildren( sourceSite.id, rewindId, '/wp-content/', false ); + if ( ! isCancelled ) { + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, 'wp-content', { children } ) + ); + } + } catch ( error ) { + if ( ! isCancelled ) { + setFileTreeError( error instanceof Error ? error : new Error( String( error ) ) ); + } + } finally { + if ( ! isCancelled ) { + setIsLoadingFileTree( false ); + } + } + }; + + void loadSourceFileTree(); + + return () => { + isCancelled = true; + }; + }, [ fetchChildren, isUsingSpecificFileSelection, rewindId, sourceSite.id ] ); + + const handleFileSelectionModeChange = ( nextMode: SyncFilesSelectionMode ) => { + setFileSelectionMode( nextMode ); + if ( nextMode === 'specific' ) { + setIncludeFiles( true ); + setIncludeDatabase( false ); + } + }; + + const handleIncludeFilesChange = ( checked: boolean ) => { + setIncludeFiles( checked ); + if ( ! checked ) { + setFileSelectionMode( 'all' ); + setFileTree( createEnvironmentFileTree() ); + } + }; + + const handleExpandFileTreeNode = useCallback( + async ( node: TreeNode ) => { + if ( ! rewindId || ! node.path || node.children?.length ) { + return; + } + + try { + const children = await fetchChildren( sourceSite.id, rewindId, node.path, node.checked ); + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, node.id, { + children, + loading: false, + hasError: false, + } ) + ); + } catch ( error ) { + setFileTree( ( previousFileTree ) => + updateNodeById( previousFileTree, node.id, { + children: [], + loading: false, + hasError: true, + } ) + ); + } + }, + [ fetchChildren, rewindId, sourceSite.id ] + ); + + const runEnvironmentSync = async ( allowWooSync = false ) => { + if ( ! hasSelectedOptions ) { + return; + } + + setIsSubmitting( true ); + const result = await dispatch( + stagingSyncThunks.startStagingSiteSync( { + productionSite, + stagingSite, + direction, + options: selectedOptions, + allowWooSync, + } ) + ); + setIsSubmitting( false ); + + if ( stagingSyncThunks.startStagingSiteSync.fulfilled.match( result ) ) { + onClose(); + return; + } + + const error = result.payload; + if ( isPull && includeDatabase && error?.code === 'rest_sqls_option_not_supported' ) { + const { response } = await getIpcApi().showMessageBox( { + message: __( 'Pull production database from a WooCommerce staging site?' ), + detail: __( + 'WooCommerce data can be sensitive. Confirm that the staging database should overwrite production, then Studio will retry this sync.' + ), + buttons: [ __( 'Retry with database' ), __( 'Cancel' ) ], + cancelId: 1, + } ); + + if ( response === 0 ) { + await runEnvironmentSync( true ); + } + return; + } + + getIpcApi().showErrorMessageBox( { + title: __( 'Could not sync staging site' ), + message: error?.message ?? __( 'The staging sync could not be started.' ), + } ); + }; + + return ( + +
+ +
+
+ + +
+
+ } + > +
+
+
+ +
+
+ +
+
+ { isUsingSpecificFileSelection && ( +
+ { isLoadingRewindId || isLoadingFileTree ? ( + + ) : isErrorRewindId || fileTreeError ? ( +
+ { __( + 'Could not load source site files. Please close and reopen this dialog to try again.' + ) } +
+ ) : ( + { + if ( nodeId === 'wp-content' && fileTreeError ) { + return ( +
+ { __( + 'Could not load source site files. Please close and reopen this dialog to try again.' + ) } +
+ ); + } + if ( node.hasError ) { + return ( +
+ { __( + 'Error retrieving remote files and directories. Please collapse and expand this folder to try again.' + ) } +
+ ); + } + return ( +
+ { __( 'Empty' ) } +
+ ); + } } + /> + ) } +
+ ) } +
+ +
+
+ + ); +} + +function WorkspaceSyncSection( { + label, + description, + badges, + active, + children, +}: { + label: string; + description: ReactNode; + badges?: ReactNode; + active?: boolean; + children: ReactNode; +} ) { + return ( +
+
+ + { badges } +
{ label }
+
{ children }
+
+
{ description }
+
+ ); +} + +function WorkspaceSetupSyncSection( { + localSite, + remoteSite, + remoteTargetId, + disabled, + active, + onCreateLocalSite, + onConnectRemoteSite, + isCreatingLocalSite, + isConnectingSite, +}: { + localSite?: SiteDetails; + remoteSite?: SyncSite; + remoteTargetId: Extract< WorkspaceTargetId, 'production' | 'staging' >; + disabled?: boolean; + active?: boolean; + onCreateLocalSite: ( remoteSite: SyncSite ) => void; + onConnectRemoteSite: ( remoteSite: SyncSite, localSite: SiteDetails ) => void; + isCreatingLocalSite?: boolean; + isConnectingSite?: boolean; +} ) { + const missingDescription = + remoteTargetId === 'production' + ? __( 'Connect or create a Production target before syncing this link.' ) + : __( 'Connect or create a Staging target before syncing this link.' ); + const disabledReason = __( 'Wait for the Production/Staging sync to finish.' ); + const localSetupState = remoteSite ? getRemoteSiteLocalSetupState( remoteSite ) : undefined; + const canUseLocalSetup = localSetupState?.canUseLocalSetup; + const canConnectRemoteSite = Boolean( localSite && remoteSite && canUseLocalSetup ); + const description = + remoteSite && ! canUseLocalSetup && localSetupState + ? localSetupState.description ?? missingDescription + : remoteSite && ! localSite + ? __( 'Create a local copy of this site to vibe with.' ) + : remoteSite && localSite + ? __( 'Connect this site to the local site before syncing.' ) + : missingDescription; + const label = + remoteSite?.name ?? + ( remoteTargetId === 'production' ? __( 'Production target' ) : __( 'Staging target' ) ); + const environmentType = remoteSite ? getSiteEnvironment( remoteSite ) : remoteTargetId; + + return ( + } + active={ active } + > +
+ { remoteSite && ! localSite && canUseLocalSetup ? ( + + + + ) : remoteSite && localSite && canConnectRemoteSite ? ( + + + + ) : remoteSite && localSetupState?.actionUrl ? ( + + + + ) : ( + + ) } +
+
+ ); +} + +function EnvironmentSyncRow( { + productionSite, + stagingSite, + disabled, + active, + onOpenDialog, + onCreateStagingSite, + isCreatingStagingSite, +}: { + productionSite?: SyncSite; + stagingSite?: SyncSite; + disabled: boolean; + active?: boolean; + onOpenDialog: ( direction: StagingSyncDirection ) => void; + onCreateStagingSite: ( productionSite: SyncSite ) => void; + isCreatingStagingSite?: boolean; +} ) { + const stagingSyncState = useRootSelector( + stagingSyncSelectors.selectState( productionSite?.id ) + ); + const isEnvironmentSyncing = useRootSelector( + stagingSyncSelectors.selectIsProductionSiteSyncing( productionSite?.id ) + ); + const hasKnownStagingSite = Boolean( productionSite?.stagingSiteIds?.length ); + const stagingSetupState = productionSite + ? getProductionStagingSetupState( productionSite ) + : undefined; + const canCreateStagingSite = + Boolean( productionSite && ! stagingSite && ! hasKnownStagingSite ) && + stagingSetupState?.canCreateStagingSite; + const disabledReason = disabled + ? __( 'Wait for the local sync operation to finish.' ) + : ! productionSite + ? __( 'Production site details are required before creating or syncing staging.' ) + : productionSite && ! stagingSite && hasKnownStagingSite + ? __( 'A staging site is linked to this production site but its details have not loaded yet.' ) + : undefined; + const description = ! productionSite + ? __( 'Add a Production target before creating or syncing staging.' ) + : ! stagingSite + ? hasKnownStagingSite + ? __( 'Staging site details are still loading.' ) + : stagingSetupState?.description ?? + __( 'Create a staging target from Production before syncing environments.' ) + : stagingSyncState?.status === 'failed' + ? stagingSyncState.error?.message ?? __( 'Environment sync failed.' ) + : isEnvironmentSyncing + ? __( 'Environment sync is running.' ) + : stagingSyncState?.status === 'completed' + ? __( 'Last environment sync completed.' ) + : __( 'Copy content between production and staging.' ); + const label = __( 'Production and staging' ); + const badges = ( + <> + + { stagingSite && } + + ); + + return ( + + +
+ { isEnvironmentSyncing && } + { productionSite && stagingSite ? ( + <> + + + + ) : productionSite && canCreateStagingSite ? ( + + ) : productionSite && ! stagingSite && stagingSetupState?.actionUrl ? ( + + ) : ( + + ) } +
+
+
+ ); +} + +export function WorkspaceSyncPanelContent( { + workspace, + selectedTargetId, +}: WorkspaceSyncPanelContentProps ) { + const dispatch = useAppDispatch(); + const { createSiteFromRemoteSite } = useAddSite(); + const { refreshWorkspaces } = useWorkspaceSelection(); + const [ connectSite ] = useConnectSiteMutation(); + const [ disconnectSite ] = useDisconnectSiteMutation(); + const [ environmentSyncDirection, setEnvironmentSyncDirection ] = + useState< StagingSyncDirection | null >( null ); + const [ creatingLocalSiteId, setCreatingLocalSiteId ] = useState< number | null >( null ); + const [ connectingRemoteSiteId, setConnectingRemoteSiteId ] = useState< number | null >( null ); + const [ creatingStagingProductionSiteId, setCreatingStagingProductionSiteId ] = useState< + number | null + >( null ); + const localSite = workspace.targets.local?.site; + const productionSite = workspace.targets.production?.site; + const stagingSite = workspace.targets.staging?.site; + const localProductionSyncState = useLocalRemoteSyncState( localSite, productionSite ); + const localStagingSyncState = useLocalRemoteSyncState( localSite, stagingSite ); + const isProductionConnectedToLocal = isRemoteConnectedToLocal( productionSite, localSite ); + const isStagingConnectedToLocal = isRemoteConnectedToLocal( stagingSite, localSite ); + const connectedLocalSites = useMemo( + () => + [ productionSite, stagingSite ].filter( + ( site ): site is SyncSite => Boolean( site ) && isRemoteConnectedToLocal( site, localSite ) + ), + [ localSite, productionSite, stagingSite ] + ); + const isEnvironmentSyncing = useRootSelector( + stagingSyncSelectors.selectIsProductionSiteSyncing( productionSite?.id ) + ); + const isAnyLocalRemoteSyncing = + localProductionSyncState.isSyncing || localStagingSyncState.isSyncing; + const shouldShowLocalProductionSetupRow = Boolean( + ( productionSite && ! isProductionConnectedToLocal ) || ( localSite && ! productionSite ) + ); + const shouldShowLocalStagingSetupRow = Boolean( + stagingSite && ! isStagingConnectedToLocal && ( localSite || ! productionSite ) + ); + const shouldShowEnvironmentRow = Boolean( productionSite || stagingSite ); + + const handleCreateLocalSite = useCallback( + async ( remoteSite: SyncSite ) => { + setCreatingLocalSiteId( remoteSite.id ); + try { + await createSiteFromRemoteSite( remoteSite ); + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create local site' ), + message: + error instanceof Error + ? error.message + : __( 'The local site could not be created from this WordPress.com site.' ), + } ); + } finally { + setCreatingLocalSiteId( null ); + } + }, + [ createSiteFromRemoteSite, refreshWorkspaces ] + ); + + const handleConnectRemoteSite = useCallback( + async ( remoteSite: SyncSite, site: SiteDetails ) => { + setConnectingRemoteSiteId( remoteSite.id ); + try { + const result = await connectSite( { site: remoteSite, localSiteId: site.id } ); + if ( 'error' in result ) { + throw result.error; + } + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not connect site' ), + message: + error instanceof Error + ? error.message + : __( 'The WordPress.com site could not be connected to this local site.' ), + } ); + } finally { + setConnectingRemoteSiteId( null ); + } + }, + [ connectSite, refreshWorkspaces ] + ); + + const handleDisconnectSite = useCallback( + async ( siteId: number ) => { + if ( ! localSite ) { + return; + } + + try { + await disconnectSite( { siteId, localSiteId: localSite.id } ); + refreshWorkspaces(); + } catch ( error ) { + getIpcApi().showErrorMessageBox( { + title: __( 'Could not disconnect site' ), + message: + error instanceof Error + ? error.message + : __( 'The WordPress.com site could not be disconnected.' ), + } ); + } + }, + [ disconnectSite, localSite, refreshWorkspaces ] + ); + + const handleCreateStagingSite = useCallback( + async ( site: SyncSite ) => { + setCreatingStagingProductionSiteId( site.id ); + const result = await dispatch( + stagingSyncThunks.createStagingSite( { productionSite: site } ) + ); + setCreatingStagingProductionSiteId( null ); + + if ( stagingSyncThunks.createStagingSite.fulfilled.match( result ) ) { + getIpcApi().showNotification( { + title: site.name, + body: __( 'Staging site created' ), + } ); + refreshWorkspaces(); + return; + } + + getIpcApi().showErrorMessageBox( { + title: __( 'Could not create staging site' ), + message: + result.payload?.message ?? + __( 'The staging site could not be created for this production site.' ), + } ); + }, + [ dispatch, refreshWorkspaces ] + ); + + useEffect( () => { + if ( ! productionSite?.id || ! stagingSite?.id ) { + return; + } + + void dispatch( + stagingSyncThunks.fetchStagingSiteSyncState( { productionSiteId: productionSite.id } ) + ); + }, [ dispatch, productionSite?.id, stagingSite?.id ] ); + + useEffect( () => { + if ( ! productionSite?.id || ! stagingSite?.id || ! isEnvironmentSyncing ) { + return; + } + + const intervalId = window.setInterval( () => { + void dispatch( + stagingSyncThunks.fetchStagingSiteSyncState( { productionSiteId: productionSite.id } ) + ); + }, 3000 ); + + return () => window.clearInterval( intervalId ); + }, [ dispatch, isEnvironmentSyncing, productionSite?.id, stagingSite?.id ] ); + + if ( + connectedLocalSites.length === 0 && + ! shouldShowLocalProductionSetupRow && + ! shouldShowLocalStagingSetupRow && + ! shouldShowEnvironmentRow + ) { + return ( +
+
+ { __( 'No workspace sync links are available yet.' ) } +
+
+ ); + } + + const syncSections = ( + <> + { shouldShowLocalProductionSetupRow && ( + + ) } + { shouldShowLocalStagingSetupRow && ( + + ) } + { shouldShowEnvironmentRow && ( + + ) } + + ); + + return ( +
+ { localSite ? ( + + { syncSections } + + ) : ( +
{ syncSections }
+ ) } + { environmentSyncDirection && productionSite && stagingSite && ( + setEnvironmentSyncDirection( null ) } + /> + ) } +
+ ); +} diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index cdd8c82ae4..d211e477e2 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -575,7 +575,7 @@ describe( 'ContentTabSync', () => { // Open specific files and folders selector const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); // Check plugins and uploads const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); @@ -654,7 +654,7 @@ describe( 'ContentTabSync', () => { await screen.findByText( 'Pull from Production' ); const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); fireEvent.click( pluginsCheckbox ); const filesAndFoldersCheckbox = screen.getByRole( 'checkbox', { name: 'Files and folders' } ); @@ -711,7 +711,7 @@ describe( 'ContentTabSync', () => { await screen.findByText( 'Push to Production' ); const select = screen.getByRole( 'combobox', { name: 'Select files and folders to sync' } ); - fireEvent.change( select, { target: { value: 'true' } } ); + fireEvent.change( select, { target: { value: 'specific' } } ); const pluginsCheckbox = screen.getByRole( 'checkbox', { name: 'plugins' } ); fireEvent.click( pluginsCheckbox ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx new file mode 100644 index 0000000000..569bf9df56 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-content-shell.tsx @@ -0,0 +1,532 @@ +import { TabPanel } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useMemo, useState, type CSSProperties } from 'react'; +import { ContentTabAssistant } from 'src/components/content-tab-assistant'; +import { ContentTabImportExport } from 'src/components/content-tab-import-export'; +import { ContentTabOverview } from 'src/components/content-tab-overview'; +import { ContentTabPreviews } from 'src/components/content-tab-previews'; +import { ContentTabSettings } from 'src/components/content-tab-settings'; +import { SiteIsBeingCreated } from 'src/components/site-is-being-created'; +import { MIN_WIDTH_CLASS_TO_MEASURE } from 'src/constants'; +import { useContentTabs, type TabName } from 'src/hooks/use-content-tabs'; +import { useImportExport } from 'src/hooks/use-import-export'; +import { useSiteDetails } from 'src/hooks/use-site-details'; +import { cx } from 'src/lib/cx'; +import { ContentTabSync } from 'src/modules/sync'; +import { WorkspaceSyncPanelContent } from 'src/modules/sync/components/workspace-sync-panel'; +import { useWorkspaceSelection } from 'src/modules/workspaces'; +import { WorkspaceDollyAssistant } from 'src/modules/workspaces/components/workspace-dolly-assistant'; +import { WorkspaceHeader } from 'src/modules/workspaces/components/workspace-header'; +import { + WorkspaceLiveSiteOverview, + WorkspaceLiveSiteSettings, +} from 'src/modules/workspaces/components/workspace-live-site-panels'; +import { + createDefaultWorkspacePreviewState, + resolveWorkspacePreviewUrl, + WorkspacePreviewControls, + WorkspacePreviewPanel, + type WorkspacePreviewState, + type WorkspacePreviewTarget, +} from 'src/modules/workspaces/components/workspace-preview'; +import type { + RemoteTarget, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; + +type WorkspaceShellPreviewTarget = WorkspacePreviewTarget & { + id: WorkspaceTargetId; + targetId: WorkspaceTargetId; + label: string; + siteId?: number | string; +}; + +function EmptyWorkspaceSelection() { + return ( +
+

+ { __( 'Select a workspace to view details.' ) } +

+
+ ); +} + +function SettingsRow( { label, value }: { label: string; value?: string | number | null } ) { + return ( +
+
{ label }
+
{ value || __( 'Unknown' ) }
+
+ ); +} + +function WorkspaceSettingsPlaceholder( { workspace }: { workspace: StudioWorkspace } ) { + const targets = [ workspace.targets.production, workspace.targets.staging ].filter( + ( target ): target is RemoteTarget => Boolean( target ) + ); + + return ( +
+
+

{ __( 'Settings' ) }

+
+ + { targets.map( ( target ) => ( + + ) ) } +
+
+
+ ); +} + +function LocalOnlyWorkspaceTabNotice( { title }: { title: string } ) { + return ( +
+
+

{ title }

+

+ { __( 'This section is managed in the Local target.' ) } +

+
+
+ ); +} + +function resolveLocalPreviewBaseUrl( site: SiteDetails ) { + if ( site.running ) { + return site.url; + } + + const protocol = site.customDomain && site.enableHttps ? 'https' : 'http'; + const domain = site.customDomain || `localhost:${ site.port }`; + + return `${ protocol }://${ domain }`; +} + +function getDefaultPreviewTargetId( workspace: StudioWorkspace ): WorkspaceTargetId | undefined { + if ( workspace.targets.local ) { + return 'local'; + } + if ( workspace.targets.staging ) { + return 'staging'; + } + if ( workspace.targets.production ) { + return 'production'; + } +} + +function getUrlPath( url: URL ) { + return `${ url.pathname }${ url.search }${ url.hash }` || '/'; +} + +function getTargetScopedPathOrUrl( pathOrUrl: string, targetSiteUrl: string ) { + try { + const requestedUrl = new URL( pathOrUrl ); + const targetUrl = new URL( targetSiteUrl ); + + if ( requestedUrl.origin !== targetUrl.origin ) { + return getUrlPath( requestedUrl ); + } + } catch { + return pathOrUrl; + } + + return pathOrUrl; +} + +function isCurrentUrlForTarget( currentUrl: string | undefined, targetSiteUrl: string ) { + if ( ! currentUrl ) { + return false; + } + + try { + return new URL( currentUrl ).origin === new URL( targetSiteUrl ).origin; + } catch { + return false; + } +} + +function getPreviewStateForTarget( + previewState: WorkspacePreviewState, + target: WorkspaceShellPreviewTarget +) { + const pathOrUrl = getTargetScopedPathOrUrl( previewState.pathOrUrl, target.siteUrl ); + const currentUrl = isCurrentUrlForTarget( previewState.currentUrl, target.siteUrl ) + ? previewState.currentUrl + : undefined; + + if ( pathOrUrl === previewState.pathOrUrl && currentUrl === previewState.currentUrl ) { + return previewState; + } + + return { + ...previewState, + pathOrUrl, + currentUrl, + canGoBack: currentUrl ? previewState.canGoBack : false, + canGoForward: currentUrl ? previewState.canGoForward : false, + navigationAction: currentUrl ? previewState.navigationAction : undefined, + }; +} + +function getTransportTarget( workspace: StudioWorkspace ) { + return workspace.targets.staging ?? workspace.targets.production; +} + +function getSelectedRemoteTarget( workspace: StudioWorkspace, targetId?: WorkspaceTargetId ) { + if ( targetId === 'production' ) { + return workspace.targets.production; + } + if ( targetId === 'staging' ) { + return workspace.targets.staging; + } +} + +export function WorkspaceContentShell() { + const { tabs } = useContentTabs(); + const { importState } = useImportExport(); + const { loadingServer, siteCreationMessages, startServer } = useSiteDetails(); + const { selectedWorkspace, selectedTabId, selectWorkspaceTab } = useWorkspaceSelection(); + const [ previewStates, setPreviewStates ] = useState< Record< string, WorkspacePreviewState > >( + {} + ); + const [ selectedPreviewTargetIds, setSelectedPreviewTargetIds ] = useState< + Record< string, WorkspaceTargetId > + >( {} ); + + const localTarget = selectedWorkspace?.targets.local; + const transportTarget = selectedWorkspace ? getTransportTarget( selectedWorkspace ) : undefined; + const previewKey = selectedWorkspace?.id ?? ''; + + const previewTargets = useMemo< WorkspaceShellPreviewTarget[] >( () => { + if ( ! selectedWorkspace ) { + return []; + } + + const targets: WorkspaceShellPreviewTarget[] = []; + if ( selectedWorkspace.targets.local ) { + const site = selectedWorkspace.targets.local.site; + targets.push( { + id: 'local', + targetId: 'local', + label: __( 'Local' ), + siteId: site.id, + siteName: site.name, + siteUrl: resolveLocalPreviewBaseUrl( site ), + isLoading: loadingServer[ site.id ], + onShowPreview: async () => { + if ( ! site.running ) { + await startServer( site ); + } + }, + } ); + } + if ( selectedWorkspace.targets.staging ) { + targets.push( { + id: 'staging', + targetId: 'staging', + label: __( 'Staging' ), + siteName: selectedWorkspace.targets.staging.site.name, + siteUrl: selectedWorkspace.targets.staging.site.url, + siteId: selectedWorkspace.targets.staging.site.id, + } ); + } + if ( selectedWorkspace.targets.production ) { + targets.push( { + id: 'production', + targetId: 'production', + label: __( 'Production' ), + siteName: selectedWorkspace.targets.production.site.name, + siteUrl: selectedWorkspace.targets.production.site.url, + siteId: selectedWorkspace.targets.production.site.id, + isProduction: true, + } ); + } + return targets; + }, [ loadingServer, selectedWorkspace, startServer ] ); + const previewState = previewStates[ previewKey ] ?? createDefaultWorkspacePreviewState(); + const selectedPreviewTargetId = selectedWorkspace + ? selectedPreviewTargetIds[ selectedWorkspace.id ] ?? + getDefaultPreviewTargetId( selectedWorkspace ) + : undefined; + const previewTarget = + previewTargets.find( ( target ) => target.id === selectedPreviewTargetId ) ?? + previewTargets[ 0 ]; + const workspaceTabs = tabs; + + if ( ! selectedWorkspace ) { + return ; + } + + if ( localTarget ) { + const selectedSite = localTarget.site; + const siteImportState = importState[ selectedSite.id ]; + const creationMessage = selectedSite.id ? siteCreationMessages[ selectedSite.id ] : undefined; + + if ( selectedSite.isAddingSite || siteImportState?.isNewSite ) { + return ( + + ); + } + } + + const activeTabId = selectedTabId ?? 'overview'; + const targetPreviewState = previewTarget + ? getPreviewStateForTarget( previewState, previewTarget ) + : previewState; + const resolvedPreviewUrl = previewTarget + ? resolveWorkspacePreviewUrl( previewTarget.siteUrl, targetPreviewState.pathOrUrl ) + : undefined; + const previewUrl = targetPreviewState.currentUrl ?? resolvedPreviewUrl; + const localContextSite = previewTarget?.id === 'local' ? localTarget?.site : undefined; + const selectedRemoteTarget = getSelectedRemoteTarget( selectedWorkspace, previewTarget?.id ); + const previewControlsAreClosed = Boolean( previewTarget && ! targetPreviewState.open ); + const previewControlsStyle = { + '--workspace-preview-controls-width': `${ targetPreviewState.width }px`, + } as CSSProperties; + + const updatePreviewState = ( nextPreviewState: WorkspacePreviewState ) => { + if ( ! previewKey ) { + return; + } + + setPreviewStates( ( current ) => ( { + ...current, + [ previewKey ]: nextPreviewState, + } ) ); + }; + + const selectPreviewTarget = ( targetId: WorkspaceTargetId ) => { + const target = previewTargets.find( ( candidate ) => candidate.id === targetId ); + setSelectedPreviewTargetIds( ( current ) => ( { + ...current, + [ selectedWorkspace.id ]: targetId, + } ) ); + setPreviewStates( ( current ) => { + const currentPreviewState = current[ previewKey ] ?? createDefaultWorkspacePreviewState(); + const nextPreviewState = target + ? getPreviewStateForTarget( currentPreviewState, target ) + : currentPreviewState; + return { + ...current, + [ previewKey ]: { + ...nextPreviewState, + canGoBack: false, + canGoForward: false, + currentUrl: undefined, + navigationAction: undefined, + }, + }; + } ); + }; + + const updatePreviewNavigationState = ( + navigationState: Pick< WorkspacePreviewState, 'canGoBack' | 'canGoForward' | 'currentUrl' > + ) => { + if ( ! previewKey ) { + return; + } + + setPreviewStates( ( current ) => { + const currentPreviewState = current[ previewKey ] ?? createDefaultWorkspacePreviewState(); + const nextPreviewState = previewTarget + ? getPreviewStateForTarget( currentPreviewState, previewTarget ) + : currentPreviewState; + + if ( + nextPreviewState.canGoBack === navigationState.canGoBack && + nextPreviewState.canGoForward === navigationState.canGoForward && + nextPreviewState.currentUrl === navigationState.currentUrl + ) { + return current; + } + + return { + ...current, + [ previewKey ]: { + ...nextPreviewState, + ...navigationState, + }, + }; + } ); + }; + + const openPreviewTarget = ( + targetId: WorkspaceTargetId, + pathOrUrl = '/', + nextPreviewState: WorkspacePreviewState + ) => { + void pathOrUrl; + const target = previewTargets.find( ( candidate ) => candidate.id === targetId ); + if ( ! target ) { + return; + } + void target.onShowPreview?.(); + const nextTargetPreviewState = getPreviewStateForTarget( nextPreviewState, target ); + setSelectedPreviewTargetIds( ( current ) => ( { + ...current, + [ selectedWorkspace.id ]: targetId, + } ) ); + setPreviewStates( ( current ) => ( { + ...current, + [ selectedWorkspace.id ]: nextTargetPreviewState, + } ) ); + }; + + const startLocalSiteFromHeader = async ( site: SiteDetails ) => { + await startServer( site ); + if ( previewTarget?.id !== 'local' ) { + return; + } + updatePreviewState( { + ...targetPreviewState, + currentUrl: resolveWorkspacePreviewUrl( previewTarget.siteUrl, targetPreviewState.pathOrUrl ), + reloadNonce: targetPreviewState.reloadNonce + 1, + } ); + }; + + return ( +
+ +
+ { previewTarget && ( +
+
+ +
+
+ ) } +
+ + selectWorkspaceTab( selectedWorkspace.id, tabName as TabName ) + } + initialTabName={ activeTabId } + key={ selectedWorkspace.id } + > + { ( { name } ) => ( +
+ { name === 'overview' && + ( localContextSite ? ( + + ) : selectedRemoteTarget ? ( + + ) : ( + + ) ) } + { name === 'previews' && + ( localContextSite ? ( + + ) : ( + + ) ) } + { name === 'import-export' && + ( localContextSite ? ( + + ) : ( + + ) ) } + { name === 'sync' && + ( selectedWorkspace.syncLinks.length ? ( + + ) : localContextSite ? ( + + ) : ( + + ) ) } + { name === 'settings' && + ( localContextSite ? ( + + ) : selectedRemoteTarget ? ( + + ) : ( + + ) ) } + { name === 'assistant' && + ( localContextSite ? ( + + ) : transportTarget ? ( + + ) : localTarget ? ( + + ) : null ) } +
+ ) } +
+
+ { previewTarget && targetPreviewState.open && previewUrl && ( + updatePreviewState( { ...targetPreviewState, width } ) } + onNavigationStateChange={ updatePreviewNavigationState } + /> + ) } +
+
+ ); +} diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx new file mode 100644 index 0000000000..d71cb596aa --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.test.tsx @@ -0,0 +1,855 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi } from 'vitest'; +import { AuthContext, type AuthContextType } from 'src/components/auth-provider'; +import { WorkspaceDollyAssistant } from 'src/modules/workspaces/components/workspace-dolly-assistant'; +import { + createDefaultWorkspacePreviewState, + type WorkspacePreviewState, +} from 'src/modules/workspaces/components/workspace-preview'; +import { hydrateWorkspaceDollyConversationStates } from 'src/modules/workspaces/lib/dolly/api'; +import { + clearWorkspaceDollyAssistantStateCacheForTests, + getWorkspaceDollyConversationsForWorkspace, + getWorkspaceDollyConversationState, + mergeWorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/session'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { ReactNode } from 'react'; +import type { + RemoteTarget, + RemoteTargetId, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; +import type { WPCOM } from 'wpcom/types'; + +vi.mock( '@automattic/agenttic-ui', async () => { + const React = await vi.importActual< typeof import('react') >( 'react' ); + + type MockAgentMessage = { + id: string; + content: Array< { + text?: string; + component?: React.ComponentType< { images?: Array< { name: string; url: string } > } >; + componentProps?: { images?: Array< { name: string; url: string } > }; + } >; + }; + type MockAction = { + id: string; + icon: ReactNode; + onClick: ( event?: React.MouseEvent< HTMLButtonElement > ) => void; + disabled?: boolean; + 'aria-label': string; + }; + type MockNotice = { + message?: string; + action?: { + label: string; + onClick: () => void; + }; + }; + type MockContainerProps = { + children?: ReactNode; + className?: string; + messages: MockAgentMessage[]; + isProcessing: boolean; + onSubmit: ( value: string ) => void; + onStop: () => void; + inputValue: string; + onInputChange: ( value: string ) => void; + placeholder?: string; + notice?: MockNotice; + }; + type MockChildrenProps = { + children?: ReactNode; + className?: string; + }; + type MockInputProps = { + disabled?: boolean; + customActions?: MockAction[]; + }; + type MockImageUploaderHandle = { + openFileDialog: () => void; + }; + type MockImageUploaderProps = { + images: Array< { id: string; name?: string; url: string } >; + onFilesSelected: ( files: File[] ) => void; + onRemoveImage: ( image: { id: string; name?: string; url: string } ) => void; + acceptedFileTypes?: string[]; + }; + + const MockAgentUIContext = React.createContext< MockContainerProps | undefined >( undefined ); + const useMockAgentUIContext = () => { + const context = React.useContext( MockAgentUIContext ); + if ( ! context ) { + throw new Error( 'Missing mocked AgentUI context.' ); + } + return context; + }; + + const Container = ( props: MockContainerProps ) => + React.createElement( + MockAgentUIContext.Provider, + { value: props }, + React.createElement( 'div', { className: props.className }, props.children ) + ); + + const ConversationView = React.forwardRef< HTMLDivElement, MockChildrenProps >( + ( { children, className }, ref ) => React.createElement( 'div', { ref, className }, children ) + ); + + const Messages = () => { + const { messages, isProcessing } = useMockAgentUIContext(); + return React.createElement( + 'div', + { 'data-slot': 'messages' }, + ...messages.map( ( message ) => + React.createElement( + 'div', + { key: message.id }, + message.content.map( ( part, index ) => { + if ( part.component ) { + return React.createElement( part.component, { + key: index, + ...( part.componentProps ?? {} ), + } ); + } + return part.text ?? ''; + } ) + ) + ), + isProcessing ? React.createElement( 'div', { key: 'thinking' }, 'Thinking...' ) : null + ); + }; + + const Footer = ( { children, className }: MockChildrenProps ) => + React.createElement( 'div', { className }, children ); + + const Notice = () => { + const { notice } = useMockAgentUIContext(); + if ( ! notice?.message ) { + return null; + } + + return React.createElement( + 'div', + null, + notice.message, + notice.action + ? React.createElement( + 'button', + { type: 'button', onClick: notice.action.onClick }, + notice.action.label + ) + : null + ); + }; + + const Input = ( { disabled, customActions = [] }: MockInputProps ) => { + const { inputValue, isProcessing, onInputChange, onStop, onSubmit, placeholder } = + useMockAgentUIContext(); + const isSendDisabled = disabled || ( ! inputValue.trim() && ! isProcessing ); + + return React.createElement( + 'div', + null, + React.createElement( 'textarea', { + value: inputValue, + placeholder, + onChange: ( event: { target: { value: string } } ) => onInputChange( event.target.value ), + onKeyDown: ( event: KeyboardEvent ) => { + if ( event.key === 'Enter' && ! isSendDisabled ) { + event.preventDefault(); + onSubmit( inputValue ); + } + }, + } ), + ...customActions.map( ( action ) => + React.createElement( + 'button', + { + key: action.id, + type: 'button', + 'aria-label': action[ 'aria-label' ], + disabled: action.disabled, + onClick: action.onClick, + }, + action[ 'aria-label' ] + ) + ), + React.createElement( + 'button', + { + type: 'button', + 'aria-label': isProcessing ? 'Stop processing' : 'Send message', + disabled: isSendDisabled, + onClick: () => ( isProcessing ? onStop() : onSubmit( inputValue ) ), + }, + isProcessing ? 'Stop' : 'Send' + ) + ); + }; + + const ImageUploader = React.forwardRef< MockImageUploaderHandle, MockImageUploaderProps >( + ( { images, onFilesSelected, onRemoveImage, acceptedFileTypes }, ref ) => { + const inputRef = React.useRef< HTMLInputElement >( null ); + React.useImperativeHandle( ref, () => ( { + openFileDialog: () => inputRef.current?.click(), + } ) ); + + return React.createElement( + 'div', + null, + React.createElement( 'input', { + ref: inputRef, + type: 'file', + 'aria-label': 'Image upload input', + accept: acceptedFileTypes?.join( ',' ), + onChange: ( event: { target: HTMLInputElement } ) => { + onFilesSelected( Array.from( event.target.files ?? [] ) ); + }, + } ), + ...images.map( ( image ) => + React.createElement( + 'button', + { + key: image.id, + type: 'button', + onClick: () => onRemoveImage( image ), + }, + image.name ?? image.id + ) + ) + ); + } + ); + + return { + AgentUI: { + Container, + ConversationView, + Messages, + Footer, + Notice, + Input, + }, + ImageUploader, + createMessageRenderer: vi.fn(), + }; +} ); + +vi.mock( 'src/hooks/use-offline', () => ( { + useOffline: () => false, +} ) ); + +vi.mock( 'src/lib/get-ipc-api', () => ( { + getIpcApi: () => ( { + getAuthenticationToken: vi.fn().mockResolvedValue( { + accessToken: 'test-token', + expiresIn: 1209600, + expirationTime: Date.now() + 1209600000, + id: 1, + email: 'test@example.com', + displayName: 'Test User', + } ), + } ), +} ) ); + +const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( { + id: 101, + localSiteId: '', + name: 'Production Site', + url: 'https://production.example', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, + ...overrides, +} ); + +const productionSite = createSyncSite( { + id: 101, + name: 'Production Site', + url: 'https://production.example', + stagingSiteIds: [ 202 ], +} ); + +const stagingSite = createSyncSite( { + id: 202, + name: 'Staging Site', + url: 'https://staging.example', + isStaging: true, + productionSiteId: 101, +} ); + +const createRemoteTarget = ( id: RemoteTargetId, site: SyncSite ): RemoteTarget => ( { + id, + kind: 'remote', + siteId: site.id, + site, +} ); + +const productionTarget = createRemoteTarget( 'production', productionSite ); +const stagingTarget = createRemoteTarget( 'staging', stagingSite ); +const previewTargets = [ + { + targetId: 'staging' as const, + siteId: stagingSite.id, + siteName: stagingSite.name, + siteUrl: stagingSite.url, + }, + { + targetId: 'production' as const, + siteId: productionSite.id, + siteName: productionSite.name, + siteUrl: productionSite.url, + isProduction: true, + }, +]; +const localSite = { + id: 'local-site-1', + name: 'Local Site', + path: '/fake/local-site', + running: true, + port: 8881, + phpVersion: '8.4', + url: 'http://localhost:8881', +} as SiteDetails; +const localPreviewTarget = { + targetId: 'local' as const, + siteId: localSite.id, + siteName: localSite.name, + siteUrl: 'http://localhost:8881', +}; + +const workspace: StudioWorkspace = { + id: 'studio-workspace:wpcom:101', + name: 'Production Site', + targets: { + production: productionTarget, + staging: stagingTarget, + }, + syncLinks: [], + activity: { status: 'idle' }, +}; +const workspaceWithLocal: StudioWorkspace = { + ...workspace, + targets: { + ...workspace.targets, + local: { + id: 'local', + kind: 'local', + siteId: localSite.id, + site: localSite, + }, + }, +}; + +type DollyFetchHandler = ( args: { + body: { + method?: string; + params?: { + id?: string; + sessionId?: string; + message?: { + parts?: Array< { + type?: string; + text?: string; + data?: Record< string, unknown >; + } >; + }; + }; + }; + init: RequestInit; + url: string; + callIndex: number; +} ) => unknown | Promise< unknown >; + +const createDollyFetchResponse = ( data: unknown, requestBody?: { method?: string } ) => { + if ( requestBody?.method === 'message/stream' ) { + const encoder = new TextEncoder(); + const stream = new ReadableStream( { + start( controller ) { + controller.enqueue( encoder.encode( `data: ${ JSON.stringify( data ) }\n\n` ) ); + controller.close(); + }, + } ); + + return new Response( stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + }, + } ); + } + + return new Response( JSON.stringify( data ), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } ); +}; + +const mockDollyFetch = ( handler: DollyFetchHandler ) => { + const requestBodies: Array< Parameters< DollyFetchHandler >[ 0 ][ 'body' ] > = []; + const requestUrls: string[] = []; + const fetchMock = vi.fn( async ( input: RequestInfo | URL, init?: RequestInit ) => { + const url = String( input ); + const body = JSON.parse( String( init?.body ?? '{}' ) ); + const callIndex = requestBodies.length; + requestBodies.push( body ); + requestUrls.push( url ); + + return createDollyFetchResponse( + await handler( { + body, + init: init ?? {}, + url, + callIndex, + } ), + body + ); + } ); + + vi.stubGlobal( 'fetch', fetchMock ); + + return { + fetchMock, + requestBodies, + requestUrls, + }; +}; + +const createDollyResponse = ( text: string, sessionId: string, taskId = 'task-1' ) => ( { + jsonrpc: '2.0', + id: 'rpc-1', + result: { + id: taskId, + sessionId, + status: { + state: 'completed', + message: { + kind: 'message', + messageId: `${ taskId }-message`, + role: 'agent', + parts: [ + { + type: 'text', + text, + }, + ], + }, + }, + }, +} ); + +const unauthenticatedClient = { req: {} } as unknown as WPCOM; + +const renderDollyAssistant = ( { + transportTarget = stagingTarget, + client = unauthenticatedClient, + previewState = createDefaultWorkspacePreviewState(), + previewTargetId = 'staging', + onOpenPreviewTarget = vi.fn(), +}: { + transportTarget?: RemoteTarget; + client?: WPCOM; + previewState?: WorkspacePreviewState; + previewTargetId?: WorkspaceTargetId; + onOpenPreviewTarget?: ( + targetId: 'local' | 'production' | 'staging', + pathOrUrl: string, + state: WorkspacePreviewState + ) => void; +} = {} ) => { + const authContextValue: AuthContextType = { + client, + isAuthenticated: true, + authenticate: vi.fn(), + logout: vi.fn().mockResolvedValue( undefined ), + }; + + return render( + + + + ); +}; + +const getInput = () => screen.getByRole( 'textbox' ); + +const getChatMessageText = ( text: string | RegExp ) => + screen.getAllByText( text ).find( ( element ) => element.closest( '[data-slot="messages"]' ) ) ?? + screen.getByText( text ); + +describe( 'WorkspaceDollyAssistant', () => { + beforeEach( () => { + vi.clearAllMocks(); + clearWorkspaceDollyAssistantStateCacheForTests(); + localStorage.clear(); + window.HTMLElement.prototype.scrollTo = vi.fn(); + Object.defineProperty( URL, 'createObjectURL', { + configurable: true, + value: vi.fn( () => 'blob:workspace-dolly-image' ), + } ); + Object.defineProperty( URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + } ); + vi.stubGlobal( + 'fetch', + vi.fn( async () => { + throw new Error( 'Unexpected fetch request' ); + } ) + ); + } ); + + afterAll( () => { + vi.unstubAllGlobals(); + } ); + + it( 'keeps workspace chat in one session while using one remote transport endpoint', async () => { + const { requestBodies, requestUrls } = mockDollyFetch( ( { callIndex } ) => + createDollyResponse( + callIndex === 0 ? 'First workspace response' : 'Second workspace response', + 'session-workspace', + callIndex === 0 ? 'task-first' : 'task-second' + ) + ); + renderDollyAssistant(); + + fireEvent.change( getInput(), { target: { value: 'Hello production' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'First workspace response' ) ).toBeVisible(); + } ); + + fireEvent.change( getInput(), { target: { value: 'Hello staging' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Second workspace response' ) ).toBeVisible(); + } ); + + expect( requestUrls ).toEqual( [ + 'https://public-api.wordpress.com/wpcom/v2/sites/202/ai/agent/dolly', + 'https://public-api.wordpress.com/wpcom/v2/sites/202/ai/agent/dolly', + ] ); + expect( requestBodies[ 0 ].params?.sessionId ).toBe( requestBodies[ 0 ].params?.id ); + expect( requestBodies[ 1 ].params?.sessionId ).toBe( 'session-workspace' ); + } ); + + it( 'sends the active remote target to Dolly without changing the workspace session', async () => { + const { requestBodies, requestUrls } = mockDollyFetch( ( { callIndex } ) => + createDollyResponse( + callIndex === 0 ? 'Staging response' : 'Production response', + 'session-workspace', + callIndex === 0 ? 'task-staging' : 'task-production' + ) + ); + const authContextValue: AuthContextType = { + client: unauthenticatedClient, + isAuthenticated: true, + authenticate: vi.fn(), + logout: vi.fn().mockResolvedValue( undefined ), + }; + const renderAssistantForTarget = ( previewTargetId: WorkspaceTargetId, currentUrl: string ) => ( + + + + ); + const { rerender } = render( + renderAssistantForTarget( 'staging', 'https://staging.example/wp-admin/edit.php' ) + ); + + fireEvent.change( getInput(), { target: { value: 'Check staging' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Staging response' ) ).toBeVisible(); + } ); + + rerender( + renderAssistantForTarget( + 'production', + 'https://production.example/wp-admin/edit.php?post=7' + ) + ); + fireEvent.change( getInput(), { target: { value: 'Now check production' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Production response' ) ).toBeVisible(); + } ); + + expect( requestUrls ).toEqual( [ + 'https://public-api.wordpress.com/wpcom/v2/sites/202/ai/agent/dolly', + 'https://public-api.wordpress.com/wpcom/v2/sites/101/ai/agent/dolly', + ] ); + expect( requestBodies[ 1 ].params?.sessionId ).toBe( 'session-workspace' ); + expect( JSON.stringify( requestBodies[ 0 ] ) ).toContain( + 'https://staging.example/wp-admin/edit.php' + ); + expect( JSON.stringify( requestBodies[ 1 ] ) ).toContain( + 'https://production.example/wp-admin/edit.php?post=7' + ); + } ); + + it( 'warns when Dolly is using the production target', () => { + renderDollyAssistant( { + transportTarget: productionTarget, + previewTargetId: 'production', + } ); + + expect( screen.getByRole( 'note' ) ).toHaveTextContent( + 'Production site: changes requested in this chat can be applied directly to the live site.' + ); + } ); + + it( 'sends the selected local preview URL and local site id while keeping the remote transport session', async () => { + const { requestBodies, requestUrls } = mockDollyFetch( () => + createDollyResponse( 'Local context response', 'session-workspace', 'task-local' ) + ); + const localPreviewState = { + ...createDefaultWorkspacePreviewState(), + open: true, + pathOrUrl: '/wp-admin/', + currentUrl: 'http://localhost:8881/wp-admin/post.php?post=7', + }; + render( + + + + ); + + fireEvent.change( getInput(), { target: { value: 'Use the local preview context' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Local context response' ) ).toBeVisible(); + } ); + + expect( requestUrls ).toEqual( [ + 'https://public-api.wordpress.com/wpcom/v2/sites/202/ai/agent/dolly', + ] ); + expect( requestBodies[ 0 ].params?.sessionId ).toBe( requestBodies[ 0 ].params?.id ); + const requestJson = JSON.stringify( requestBodies[ 0 ] ); + expect( requestJson ).toContain( '"targetId":"local"' ); + expect( requestJson ).toContain( 'local-site-1' ); + expect( requestJson ).toContain( 'http://localhost:8881/wp-admin/post.php?post=7' ); + } ); + + it( 'uploads pending images and sends them to Dolly through input actions', async () => { + const dollyRequestBodies: Array< Record< string, unknown > > = []; + const mediaRequests: string[] = []; + const fetchMock = vi.fn( async ( input: RequestInfo | URL, init?: RequestInit ) => { + const url = String( input ); + + if ( url.includes( '/rest/v1.1/sites/101/media/new' ) ) { + mediaRequests.push( url ); + expect( init?.body ).toBeInstanceOf( FormData ); + + return new Response( + JSON.stringify( { + media: [ + { + ID: 777, + URL: 'https://cdn.example/site-image.png', + mime_type: 'image/png', + file: 'site-image.png', + title: 'site-image', + }, + ], + } ), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + const body = JSON.parse( String( init?.body ?? '{}' ) ); + dollyRequestBodies.push( body ); + return createDollyFetchResponse( + createDollyResponse( 'Image response', 'session-image', 'task-image' ), + body + ); + } ); + vi.stubGlobal( 'fetch', fetchMock ); + renderDollyAssistant( { transportTarget: productionTarget, previewTargetId: 'production' } ); + + expect( screen.getByRole( 'button', { name: 'Upload image' } ) ).toBeVisible(); + + const imageFile = new File( [ 'image-bytes' ], 'site-image.png', { type: 'image/png' } ); + fireEvent.change( screen.getByLabelText( 'Image upload input' ), { + target: { files: [ imageFile ] }, + } ); + + await waitFor( () => { + expect( screen.getByText( 'site-image.png' ) ).toBeVisible(); + } ); + + fireEvent.change( getInput(), { target: { value: 'What is in this image?' } } ); + fireEvent.click( screen.getByRole( 'button', { name: 'Send message' } ) ); + + await waitFor( () => { + expect( getChatMessageText( 'Image response' ) ).toBeVisible(); + } ); + + expect( mediaRequests ).toEqual( [ + 'https://public-api.wordpress.com/rest/v1.1/sites/101/media/new', + ] ); + expect( JSON.stringify( dollyRequestBodies[ 0 ] ) ).toContain( + 'https://cdn.example/site-image.png' + ); + expect( screen.getByRole( 'button', { name: 'Chat options' } ) ).toBeVisible(); + } ); + + it( 'hydrates server conversations into the workspace chat across remote targets', async () => { + const client = { + req: { + get: vi.fn( ( { path }, callback ) => { + if ( path.startsWith( '/ai/chats/wpcom-agent-dolly' ) ) { + callback( null, [ + { + chat_id: 301, + session_id: 'server-production-session', + site_id: 101, + created_at: '2026-05-14 13:00:00', + }, + { + chat_id: 302, + session_id: 'server-staging-session', + site_id: 202, + created_at: '2026-05-14 14:00:00', + }, + ] ); + return; + } + + if ( path.startsWith( '/ai/chat/wpcom-agent-dolly/301' ) ) { + callback( null, { + chat_id: 301, + session_id: 'server-production-session', + site_id: 101, + messages: [ + { + role: 'user', + content: 'Production history question', + created_at: '2026-05-14 13:00:00', + }, + { + role: 'assistant', + content: 'Production history answer', + created_at: '2026-05-14 13:01:00', + }, + ], + } ); + return; + } + + if ( path.startsWith( '/ai/chat/wpcom-agent-dolly/302' ) ) { + callback( null, { + chat_id: 302, + session_id: 'server-staging-session', + site_id: 202, + messages: [ + { + role: 'user', + content: 'Staging history question', + created_at: '2026-05-14 14:00:00', + }, + { + role: 'assistant', + content: 'Staging history answer', + created_at: '2026-05-14 14:01:00', + }, + ], + } ); + return; + } + + throw new Error( `Unexpected history path: ${ path }` ); + } ), + }, + } as unknown as WPCOM; + + const hydratedConversationStates = await hydrateWorkspaceDollyConversationStates( client, { + workspaceId: workspace.id, + workspace, + remoteTargets: [ productionTarget, stagingTarget ], + } ); + + hydratedConversationStates.forEach( ( conversationState ) => { + mergeWorkspaceDollyConversationState( conversationState, { selectIfEmpty: true } ); + } ); + + const selectedConversation = getWorkspaceDollyConversationState( { + workspaceId: workspace.id, + workspace, + remoteTargets: [ productionTarget, stagingTarget ], + } ); + const conversations = getWorkspaceDollyConversationsForWorkspace( { + workspaceId: workspace.id, + workspace, + remoteTargets: [ productionTarget, stagingTarget ], + } ); + + expect( conversations ).toHaveLength( 2 ); + expect( + conversations.flatMap( ( conversation ) => + conversation.messages.map( ( message ) => message.content ) + ) + ).toEqual( [ + 'Staging history question', + 'Staging history answer', + 'Production history question', + 'Production history answer', + ] ); + expect( selectedConversation.sessionId ).toBe( 'server-staging-session' ); + expect( client.req.get ).toHaveBeenCalledWith( + expect.objectContaining( { + path: expect.stringContaining( '/302' ), + } ), + expect.any( Function ) + ); + } ); +} ); diff --git a/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx new file mode 100644 index 0000000000..a92a6189e5 --- /dev/null +++ b/apps/studio/src/modules/workspaces/components/workspace-dolly-assistant.tsx @@ -0,0 +1,1481 @@ +import { AgentUI, ImageUploader, createMessageRenderer } from '@automattic/agenttic-ui'; +import { Popover } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { chevronDown, Icon, image as imageIcon, moreVertical, plus, trash } from '@wordpress/icons'; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'; +import { ArrowIcon } from 'src/components/arrow-icon'; +import Button from 'src/components/button'; +import { ChatMessage } from 'src/components/chat-message'; +import offlineIcon from 'src/components/offline-icon'; +import { LIMIT_OF_PROMPTS_PER_USER } from 'src/constants'; +import { useAuth } from 'src/hooks/use-auth'; +import { useOffline } from 'src/hooks/use-offline'; +import { cx } from 'src/lib/cx'; +import { hydrateWorkspaceDollyConversationStates } from 'src/modules/workspaces/lib/dolly/api'; +import { + WORKSPACE_DOLLY_IMAGE_PREVIEW_CLASS_NAME, + WORKSPACE_DOLLY_IMAGE_PREVIEW_STYLE, + WorkspaceDollyOptimisticImages, + createWorkspaceDollyImagePrompt, + createWorkspaceDollyPendingVisibleImages, + createWorkspaceDollyVisibleMessage, + isWorkspaceDollyRenderableImageLinkUrl, + isWorkspaceDollyRenderableImageUrl, + readWorkspaceDollyFileAsDataUrl, + revokeWorkspaceDollyPendingImageUrls, + uploadWorkspaceDollyImages, +} from 'src/modules/workspaces/lib/dolly/media'; +import { + createWorkspaceDollyPreviewAbilities, + createWorkspaceDollyPreviewContext, + createWorkspaceDollySiteAssociationContext, + getNextWorkspaceDollyPreviewState, + normalizeWorkspaceDollyPreviewUrl, + type PreviewAbilityTarget, +} from 'src/modules/workspaces/lib/dolly/preview'; +import { + createNewWorkspaceDollyConversation, + createWorkspaceDollyWorkspaceDescriptor, + deleteWorkspaceDollyConversation, + getCachedWorkspaceDollyConversationState, + getWorkspaceDollyConversationState, + getWorkspaceDollyConversationsForWorkspace, + mergeWorkspaceDollyConversationState, + setSelectedWorkspaceDollyConversationId, + useSelectedWorkspaceDollyConversationId, + writeWorkspaceDollyConversationState, +} from 'src/modules/workspaces/lib/dolly/session'; +import { + getWorkspaceDollyErrorMessage, + isWorkspaceDollyRequestAbortError, + sendWorkspaceDollyMessage, +} from 'src/modules/workspaces/lib/dolly/transport'; +import { + abortWorkspaceDollyTurn, + finishWorkspaceDollyTurn, + getWorkspaceDollyTurn, + setWorkspaceDollyWorkspaceUnread, + startWorkspaceDollyTurn, + useWorkspaceDollyConversationTurn, +} from 'src/modules/workspaces/lib/dolly/turns'; +import { + WORKSPACE_DOLLY_IMAGE_FILE_TYPES, + WORKSPACE_DOLLY_IMAGE_MAX_FILE_SIZE, + WORKSPACE_DOLLY_IMAGE_MAX_FILES, +} from 'src/modules/workspaces/lib/dolly/types'; +import { generateMessage, type Message as MessageType } from 'src/stores/chat-slice'; +import type { ToolProvider } from '@automattic/agenttic-client'; +import type { + AgentUIProps, + ImageUploaderHandle, + NoticeConfig as AgentticNoticeConfig, + UploadedImage, +} from '@automattic/agenttic-ui'; +import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview'; +import type { + WorkspaceDollyConversationState, + WorkspaceDollyMessageImageAttachment, + WorkspaceDollyPendingImage, + WorkspaceDollyWorkspaceDescriptor, + WorkspaceDollyUploadedImage, +} from 'src/modules/workspaces/lib/dolly/types'; +import type { + RemoteTarget, + StudioWorkspace, + WorkspaceTargetId, +} from 'src/modules/workspaces/types'; + +type WorkspaceDollyAssistantProps = { + workspace: StudioWorkspace; + transportTarget: RemoteTarget; + previewState: WorkspacePreviewState; + previewTargetId?: WorkspaceTargetId; + previewTargets: PreviewAbilityTarget[]; + onOpenPreviewTarget: ( + targetId: WorkspaceTargetId, + pathOrUrl: string, + nextPreviewState: WorkspacePreviewState + ) => void; +}; + +function OfflineModeView() { + return ( +
+ + + { __( 'The AI assistant requires an internet connection.' ) } + +
+ ); +} + +function UnauthenticatedView( { onAuthenticate }: { onAuthenticate: () => void } ) { + return ( + +
+ { __( 'Hold up!' ) } +
+
+ { __( 'You need to log in to your WordPress.com account to use Dolly.' ) } +
+
+ { sprintf( + __( 'Every account gets %d prompts included for free each month.' ), + LIMIT_OF_PROMPTS_PER_USER + ) } +
+ +
+ ); +} + +function WorkspaceDollyEmptyView() { + return