resizeToClientX( event.clientX ) }
+ onMouseUp={ stopResizing }
+ onMouseLeave={ stopResizing }
+ />
+ ) }
+
{
+ if ( event.key === 'Escape' ) {
+ event.preventDefault();
+ stopResizing();
+ }
+ if ( event.key === 'ArrowLeft' ) {
+ event.preventDefault();
+ resizeBy( 32 );
+ }
+ if ( event.key === 'ArrowRight' ) {
+ event.preventDefault();
+ resizeBy( -32 );
+ }
+ } }
+ />
+
+ { previewUrl ? (
+
+ ) : (
+
+
+
+ { __( 'Preview unavailable' ) }
+
+
+ ) }
+
+
+ );
+}
diff --git a/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx b/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx
new file mode 100644
index 0000000000..2edd6aeea4
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/components/workspace-sidebar-row.tsx
@@ -0,0 +1,165 @@
+import { Spinner } from '@wordpress/components';
+import { sprintf, __ } from '@wordpress/i18n';
+import { useMemo } from 'react';
+import { Tooltip } from 'src/components/tooltip';
+import { isMac } from 'src/lib/app-globals';
+import { cx } from 'src/lib/cx';
+import {
+ createWorkspaceDollyWorkspaceDescriptor,
+ useSelectedWorkspaceDollyConversationId,
+ useWorkspaceDollyConversationsForWorkspace,
+} from 'src/modules/workspaces/lib/dolly/session';
+import { useWorkspaceDollyWorkspaceActivity } from 'src/modules/workspaces/lib/dolly/turns';
+import { useRootSelector } from 'src/stores';
+import { stagingSyncSelectors, syncOperationsSelectors } from 'src/stores/sync';
+import type { ReactNode } from 'react';
+import type { WorkspaceDollyConversationState } from 'src/modules/workspaces/lib/dolly/types';
+import type { StudioWorkspace } from 'src/modules/workspaces/types';
+
+type WorkspaceSidebarRowProps = {
+ workspace: StudioWorkspace;
+ isSelected: boolean;
+ localRunControl?: ReactNode;
+ onSelect: () => void;
+ onSelectChat: ( conversationId: string ) => void;
+};
+
+function isBlankConversation( conversation: WorkspaceDollyConversationState ) {
+ return conversation.messages.length === 0 && ! conversation.input.trim();
+}
+
+function getConversationUpdatedLabel( conversation: WorkspaceDollyConversationState ) {
+ return new Intl.DateTimeFormat( undefined, {
+ month: 'short',
+ day: 'numeric',
+ } ).format( new Date( conversation.lastUpdated ) );
+}
+
+function getConversationLabel( conversation: WorkspaceDollyConversationState ) {
+ const firstUserMessage = conversation.messages.find( ( message ) => message.role === 'user' );
+ const fallbackDate = getConversationUpdatedLabel( conversation );
+
+ if ( firstUserMessage?.content.trim() ) {
+ return firstUserMessage.content.trim().replace( /\s+/g, ' ' ).slice( 0, 48 );
+ }
+
+ return sprintf( __( 'Chat from %s' ), fallbackDate );
+}
+
+export function WorkspaceSidebarRow( {
+ workspace,
+ isSelected,
+ localRunControl,
+ onSelect,
+ onSelectChat,
+}: WorkspaceSidebarRowProps ) {
+ const workspaceDescriptor = useMemo(
+ () => createWorkspaceDollyWorkspaceDescriptor( workspace ),
+ [ workspace ]
+ );
+ const selectedConversationId = useSelectedWorkspaceDollyConversationId( workspaceDescriptor );
+ const recentConversations = useWorkspaceDollyConversationsForWorkspace( workspaceDescriptor )
+ .filter( ( conversation ) => ! isBlankConversation( conversation ) )
+ .slice( 0, 3 );
+ const workspaceActivity = useWorkspaceDollyWorkspaceActivity( workspace.id );
+ const localSiteId = workspace.targets.local?.siteId ?? '';
+ const productionSiteId = workspace.targets.production?.siteId;
+ const stagingSiteId = workspace.targets.staging?.siteId;
+ const isLocalPulling = useRootSelector(
+ syncOperationsSelectors.selectIsSiteIdPulling( localSiteId )
+ );
+ const isLocalPushing = useRootSelector(
+ syncOperationsSelectors.selectIsSiteIdPushing( localSiteId )
+ );
+ const isProductionStagingSyncing = useRootSelector(
+ stagingSyncSelectors.selectIsProductionSiteSyncing( productionSiteId )
+ );
+ const isStagingEnvironmentSyncing = useRootSelector(
+ stagingSyncSelectors.selectIsRemoteSiteEnvironmentSyncing( stagingSiteId )
+ );
+ const isAssistantThinking = Boolean( workspaceActivity.isAssistantThinking );
+ const isSyncing =
+ isLocalPulling || isLocalPushing || isProductionStagingSyncing || isStagingEnvironmentSyncing;
+ const showActivitySpinner = isAssistantThinking || isSyncing;
+ const activityTooltip = isAssistantThinking
+ ? isSyncing
+ ? __( 'Assistant and sync in progress' )
+ : __( 'Assistant thinking' )
+ : __( 'Syncing' );
+ const activityLabel = isAssistantThinking
+ ? isSyncing
+ ? sprintf(
+ // translators: %s is a workspace name.
+ __( '%s assistant and sync are active' ),
+ workspace.name
+ )
+ : sprintf(
+ // translators: %s is a workspace name.
+ __( '%s assistant is thinking' ),
+ workspace.name
+ )
+ : sprintf(
+ // translators: %s is a workspace name.
+ __( '%s sync is in progress' ),
+ workspace.name
+ );
+
+ return (
+
+
+
+ { workspace.name }
+
+ { showActivitySpinner ? (
+
+
+
+
+
+ ) : (
+ localRunControl
+ ) }
+
+ { recentConversations.length > 0 && (
+
+ { recentConversations.map( ( conversation ) => {
+ const label = getConversationLabel( conversation );
+ const isChatSelected = isSelected && conversation.id === selectedConversationId;
+ return (
+
+ onSelectChat( conversation.id ) }
+ >
+ { label }
+
+
+ );
+ } ) }
+
+ ) }
+
+ );
+}
diff --git a/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx b/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx
new file mode 100644
index 0000000000..1bb0a42f9d
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/components/workspace-target-indicators.tsx
@@ -0,0 +1,103 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { Tooltip } from 'src/components/tooltip';
+import { cx } from 'src/lib/cx';
+import type { StudioWorkspace, WorkspaceTargetId } from 'src/modules/workspaces/types';
+
+type WorkspaceTargetIndicatorsProps = {
+ workspace: StudioWorkspace;
+};
+
+type Indicator = {
+ targetId: WorkspaceTargetId;
+ label: string;
+ ariaLabel: string;
+ dotClassName: string;
+};
+
+export function WorkspaceTargetIndicators( { workspace }: WorkspaceTargetIndicatorsProps ) {
+ const indicators: Indicator[] = [];
+
+ if ( workspace.targets.production ) {
+ indicators.push( {
+ targetId: 'production',
+ label: __( 'Production' ),
+ ariaLabel: sprintf(
+ // translators: %s is the production site URL.
+ __( 'Production target: %s' ),
+ workspace.targets.production.site.url
+ ),
+ dotClassName: 'bg-frame-theme',
+ } );
+ }
+
+ if ( workspace.targets.staging ) {
+ indicators.push( {
+ targetId: 'staging',
+ label: __( 'Staging' ),
+ ariaLabel: sprintf(
+ // translators: %s is the staging site URL.
+ __( 'Staging target: %s' ),
+ workspace.targets.staging.site.url
+ ),
+ dotClassName: 'bg-a8c-blue-20',
+ } );
+ }
+
+ if ( workspace.targets.local ) {
+ const localSite = workspace.targets.local.site;
+ indicators.push( {
+ targetId: 'local',
+ label: __( 'Local' ),
+ ariaLabel: localSite.running
+ ? sprintf(
+ // translators: %s is the local site name.
+ __( 'Local target: %s is running' ),
+ localSite.name
+ )
+ : sprintf(
+ // translators: %s is the local site name.
+ __( 'Local target: %s is stopped' ),
+ localSite.name
+ ),
+ dotClassName: localSite.running ? 'bg-a8c-green-20' : 'bg-a8c-gray-500',
+ } );
+ }
+
+ if ( indicators.length === 0 ) {
+ return null;
+ }
+
+ const groupLabel = sprintf(
+ // translators: %s is a comma-separated list of workspace targets, such as "Production, Staging, Local".
+ __( 'Workspace targets: %s' ),
+ indicators.map( ( indicator ) => indicator.label ).join( ', ' )
+ );
+
+ return (
+
+ { indicators.map( ( indicator ) => (
+
+
+
+
+
+ ) ) }
+
+ );
+}
diff --git a/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts
new file mode 100644
index 0000000000..5440febfba
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/hooks/use-sidebar-workspaces.ts
@@ -0,0 +1,102 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAuth } from 'src/hooks/use-auth';
+import { useFeatureFlags } from 'src/hooks/use-feature-flags';
+import { useSiteDetails } from 'src/hooks/use-site-details';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { buildStudioWorkspaces } from 'src/modules/workspaces/lib/build-studio-workspaces';
+import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites';
+import type { SyncSite } from '@studio/common/types/sync';
+
+export function useSidebarWorkspaces() {
+ const { sites: localSites } = useSiteDetails();
+ const { isAuthenticated, user } = useAuth();
+ const { enableWorkspaces } = useFeatureFlags();
+ const [ connectedSites, setConnectedSites ] = useState< SyncSite[] >( [] );
+ const [ isLoadingConnectedSites, setIsLoadingConnectedSites ] = useState( false );
+ const [ connectedSitesRefreshKey, setConnectedSitesRefreshKey ] = useState( 0 );
+ const connectedSiteIds = useMemo(
+ () => connectedSites.map( ( site ) => site.id ),
+ [ connectedSites ]
+ );
+ const shouldLoadRemoteSites = enableWorkspaces && isAuthenticated;
+ const {
+ data: wpcomSitesData,
+ isFetching: isFetchingWpcomSites,
+ isLoading: isLoadingWpcomSites,
+ refetch: refetchWpcomSites,
+ } = useGetWpComSitesQuery(
+ {
+ connectedSiteIds,
+ userId: user?.id,
+ perPage: 100,
+ },
+ { skip: ! shouldLoadRemoteSites }
+ );
+
+ const refreshWorkspaces = useCallback( () => {
+ setConnectedSitesRefreshKey( ( key ) => key + 1 );
+ if ( shouldLoadRemoteSites ) {
+ void refetchWpcomSites();
+ }
+ }, [ refetchWpcomSites, shouldLoadRemoteSites ] );
+
+ useEffect( () => {
+ if ( ! shouldLoadRemoteSites ) {
+ setConnectedSites( [] );
+ setIsLoadingConnectedSites( false );
+ return;
+ }
+
+ let isCurrent = true;
+ setIsLoadingConnectedSites( true );
+ getIpcApi()
+ .getConnectedWpcomSites()
+ .then( ( sites ) => {
+ if ( isCurrent ) {
+ setConnectedSites( sites );
+ }
+ } )
+ .catch( ( error ) => {
+ console.error( 'Failed to load connected WordPress.com sites:', error );
+ if ( isCurrent ) {
+ setConnectedSites( [] );
+ }
+ } )
+ .finally( () => {
+ if ( isCurrent ) {
+ setIsLoadingConnectedSites( false );
+ }
+ } );
+
+ return () => {
+ isCurrent = false;
+ };
+ }, [ connectedSitesRefreshKey, shouldLoadRemoteSites ] );
+
+ const wpcomSites = useMemo(
+ () => ( shouldLoadRemoteSites ? wpcomSitesData?.sites ?? [] : [] ),
+ [ shouldLoadRemoteSites, wpcomSitesData?.sites ]
+ );
+ const sidebarWorkspaces = useMemo(
+ () =>
+ enableWorkspaces
+ ? buildStudioWorkspaces( {
+ localSites,
+ wpcomSites,
+ connectedSites,
+ } )
+ : [],
+ [ connectedSites, enableWorkspaces, localSites, wpcomSites ]
+ );
+
+ return {
+ enableWorkspaces,
+ sidebarWorkspaces,
+ wpcomSites,
+ connectedSites,
+ refreshWorkspaces,
+ isLoading:
+ shouldLoadRemoteSites &&
+ ( isLoadingConnectedSites || isLoadingWpcomSites || isFetchingWpcomSites ),
+ };
+}
diff --git a/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx
new file mode 100644
index 0000000000..a16be6abde
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/hooks/use-workspace-selection.tsx
@@ -0,0 +1,177 @@
+import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
+import { useSiteDetails } from 'src/hooks/use-site-details';
+import { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces';
+import type { TabName } from 'src/hooks/use-content-tabs';
+import type { StudioWorkspace } from 'src/modules/workspaces/types';
+
+type WorkspaceSelectionContextValue = {
+ enableWorkspaces: boolean;
+ workspaces: StudioWorkspace[];
+ isLoading: boolean;
+ selectedWorkspace?: StudioWorkspace;
+ selectedWorkspaceId?: string;
+ selectWorkspace: ( workspaceId: string ) => void;
+ selectedTabId?: TabName;
+ selectWorkspaceTab: ( workspaceId: string, tabId: TabName ) => void;
+ refreshWorkspaces: () => void;
+};
+
+const WorkspaceSelectionContext = createContext< WorkspaceSelectionContextValue | undefined >(
+ undefined
+);
+
+const WORKSPACE_TAB_STORAGE_PREFIX = 'studio-workspace-tab:';
+const VALID_WORKSPACE_TAB_IDS: TabName[] = [
+ 'overview',
+ 'sync',
+ 'previews',
+ 'import-export',
+ 'settings',
+ 'assistant',
+];
+
+function isWorkspaceTabId( tabId: string ): tabId is TabName {
+ return VALID_WORKSPACE_TAB_IDS.includes( tabId as TabName );
+}
+
+function getWorkspaceTabStorageKey( workspaceId: string ) {
+ return `${ WORKSPACE_TAB_STORAGE_PREFIX }${ workspaceId }`;
+}
+
+function readSavedTabId( workspaceId: string ): TabName | undefined {
+ try {
+ const savedTabId = localStorage.getItem( getWorkspaceTabStorageKey( workspaceId ) );
+ if ( savedTabId && isWorkspaceTabId( savedTabId ) ) {
+ return savedTabId;
+ }
+ } catch {
+ return undefined;
+ }
+
+ return undefined;
+}
+
+function writeSavedTabId( workspaceId: string, tabId: TabName ) {
+ try {
+ localStorage.setItem( getWorkspaceTabStorageKey( workspaceId ), tabId );
+ } catch {
+ // Ignore storage failures; selection still works for the current render.
+ }
+}
+
+export function WorkspaceSelectionProvider( { children }: { children: ReactNode } ) {
+ const { selectedSite, setSelectedSiteId } = useSiteDetails();
+ const {
+ enableWorkspaces,
+ sidebarWorkspaces: workspaces,
+ isLoading,
+ refreshWorkspaces,
+ } = useSidebarWorkspaces();
+ const [ explicitSelectedWorkspaceId, setExplicitSelectedWorkspaceId ] = useState< string >();
+ const [ selectedTabs, setSelectedTabs ] = useState< Record< string, TabName > >( {} );
+ const selectedSiteId = selectedSite?.id;
+
+ const selectedWorkspace = useMemo( () => {
+ const explicitWorkspace = workspaces.find(
+ ( workspace ) => workspace.id === explicitSelectedWorkspaceId
+ );
+ if ( explicitWorkspace ) {
+ return explicitWorkspace;
+ }
+
+ if ( selectedSiteId ) {
+ const selectedSiteWorkspace = workspaces.find(
+ ( workspace ) => workspace.targets.local?.siteId === selectedSiteId
+ );
+ if ( selectedSiteWorkspace ) {
+ return selectedSiteWorkspace;
+ }
+ }
+
+ return workspaces[ 0 ];
+ }, [ explicitSelectedWorkspaceId, selectedSiteId, workspaces ] );
+
+ const selectedTabId = useMemo( () => {
+ if ( ! selectedWorkspace ) {
+ return undefined;
+ }
+
+ const selectedTab =
+ selectedTabs[ selectedWorkspace.id ] ?? readSavedTabId( selectedWorkspace.id );
+
+ if ( selectedTab && isWorkspaceTabId( selectedTab ) ) {
+ return selectedTab;
+ }
+
+ return 'overview';
+ }, [ selectedTabs, selectedWorkspace ] );
+
+ const selectWorkspace = useCallback(
+ ( workspaceId: string ) => {
+ const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId );
+ if ( ! workspace ) {
+ return;
+ }
+
+ setExplicitSelectedWorkspaceId( workspaceId );
+ if ( workspace.targets.local ) {
+ setSelectedSiteId( workspace.targets.local.siteId );
+ }
+ },
+ [ setSelectedSiteId, workspaces ]
+ );
+
+ const selectWorkspaceTab = useCallback(
+ ( workspaceId: string, tabId: TabName ) => {
+ const workspace = workspaces.find( ( candidate ) => candidate.id === workspaceId );
+ if ( ! workspace || ! isWorkspaceTabId( tabId ) ) {
+ return;
+ }
+
+ setSelectedTabs( ( current ) => ( {
+ ...current,
+ [ workspaceId ]: tabId,
+ } ) );
+ writeSavedTabId( workspaceId, tabId );
+ },
+ [ workspaces ]
+ );
+
+ const value = useMemo< WorkspaceSelectionContextValue >( () => {
+ return {
+ enableWorkspaces,
+ workspaces,
+ isLoading,
+ selectedWorkspace,
+ selectedWorkspaceId: selectedWorkspace?.id,
+ selectWorkspace,
+ selectedTabId,
+ selectWorkspaceTab,
+ refreshWorkspaces,
+ };
+ }, [
+ enableWorkspaces,
+ isLoading,
+ refreshWorkspaces,
+ selectWorkspaceTab,
+ selectWorkspace,
+ selectedTabId,
+ selectedWorkspace,
+ workspaces,
+ ] );
+
+ return (
+
+ { children }
+
+ );
+}
+
+export function useWorkspaceSelection() {
+ const context = useContext( WorkspaceSelectionContext );
+ if ( ! context ) {
+ throw new Error( 'useWorkspaceSelection must be used within a WorkspaceSelectionProvider' );
+ }
+
+ return context;
+}
diff --git a/apps/studio/src/modules/workspaces/index.ts b/apps/studio/src/modules/workspaces/index.ts
new file mode 100644
index 0000000000..b2b3fbf15c
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/index.ts
@@ -0,0 +1,19 @@
+export {
+ buildStudioWorkspaces,
+ createStudioWorkspaceId,
+ mergeWpcomSitesWithConnectedSites,
+} from 'src/modules/workspaces/lib/build-studio-workspaces';
+export { useSidebarWorkspaces } from 'src/modules/workspaces/hooks/use-sidebar-workspaces';
+export {
+ useWorkspaceSelection,
+ WorkspaceSelectionProvider,
+} from 'src/modules/workspaces/hooks/use-workspace-selection';
+export type {
+ BuildStudioWorkspacesInput,
+ LocalTarget,
+ RemoteTarget,
+ StudioWorkspace,
+ WorkspaceActivity,
+ WorkspaceSyncLink,
+ WorkspaceTargetId,
+} from 'src/modules/workspaces/types';
diff --git a/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts
new file mode 100644
index 0000000000..59b0930723
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.test.ts
@@ -0,0 +1,296 @@
+import { buildStudioWorkspaces } from 'src/modules/workspaces';
+import type { SyncSite } from '@studio/common/types/sync';
+
+const createLocalSite = ( overrides: Partial< SiteDetails > = {} ): SiteDetails =>
+ ( {
+ id: 'local-site-id',
+ name: 'Auro Atelier',
+ path: '/tmp/auro-atelier',
+ port: 8881,
+ running: false,
+ phpVersion: '8.4',
+ ...overrides,
+ } ) as SiteDetails;
+
+const createSyncSite = ( overrides: Partial< SyncSite > = {} ): SyncSite => ( {
+ id: 101,
+ localSiteId: '',
+ name: 'Auro Atelier',
+ url: 'https://auro.example',
+ isStaging: false,
+ isPressable: false,
+ syncSupport: 'syncable',
+ lastPullTimestamp: null,
+ lastPushTimestamp: null,
+ ...overrides,
+} );
+
+describe( 'buildStudioWorkspaces', () => {
+ it( 'builds a local-only workspace', () => {
+ const localSite = createLocalSite();
+
+ const workspaces = buildStudioWorkspaces( { localSites: [ localSite ] } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:local:local-site-id',
+ name: 'Auro Atelier',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ },
+ syncLinks: [],
+ activity: { status: 'idle' },
+ } );
+ expect( workspaces[ 0 ].targets.production ).toBeUndefined();
+ expect( workspaces[ 0 ].targets.staging ).toBeUndefined();
+ } );
+
+ it( 'builds a production-only workspace', () => {
+ const productionSite = createSyncSite();
+
+ const workspaces = buildStudioWorkspaces( { wpcomSites: [ productionSite ] } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ production: { siteId: 101 },
+ },
+ syncLinks: [],
+ } );
+ expect( workspaces[ 0 ].targets.local ).toBeUndefined();
+ expect( workspaces[ 0 ].targets.staging ).toBeUndefined();
+ } );
+
+ it( 'groups production and staging targets', () => {
+ const productionSite = createSyncSite( { id: 101, stagingSiteIds: [ 202 ] } );
+ const stagingSite = createSyncSite( {
+ id: 202,
+ name: 'Auro Atelier Staging',
+ url: 'https://auro-staging.example',
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ wpcomSites: [ productionSite, stagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ production: { siteId: 101 },
+ staging: { siteId: 202 },
+ },
+ } );
+ expect( workspaces[ 0 ].syncLinks ).toEqual( [
+ { id: 'production:staging', source: 'production', target: 'staging', status: 'available' },
+ ] );
+ } );
+
+ it( 'groups local and production targets', () => {
+ const localSite = createLocalSite();
+ const productionSite = createSyncSite( {
+ id: 101,
+ localSiteId: localSite.id,
+ syncSupport: 'already-connected',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ productionSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ production: { siteId: 101 },
+ },
+ } );
+ expect( workspaces[ 0 ].syncLinks ).toEqual( [
+ { id: 'local:production', source: 'local', target: 'production', status: 'available' },
+ ] );
+ } );
+
+ it( 'groups local and staging targets', () => {
+ const localSite = createLocalSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ localSiteId: localSite.id,
+ name: 'Auro Atelier Staging',
+ isStaging: true,
+ productionSiteId: 101,
+ syncSupport: 'already-connected',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ stagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ staging: { siteId: 202 },
+ },
+ } );
+ expect( workspaces[ 0 ].syncLinks ).toEqual( [
+ { id: 'local:staging', source: 'local', target: 'staging', status: 'available' },
+ ] );
+ } );
+
+ it( 'groups local, production, and staging targets', () => {
+ const localSite = createLocalSite();
+ const productionSite = createSyncSite( {
+ id: 101,
+ localSiteId: localSite.id,
+ stagingSiteIds: [ 202 ],
+ syncSupport: 'already-connected',
+ } );
+ const stagingSite = createSyncSite( {
+ id: 202,
+ localSiteId: localSite.id,
+ name: 'Auro Atelier Staging',
+ isStaging: true,
+ productionSiteId: 101,
+ syncSupport: 'already-connected',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ productionSite, stagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ production: { siteId: 101 },
+ staging: { siteId: 202 },
+ },
+ } );
+ expect( workspaces[ 0 ].syncLinks ).toEqual( [
+ { id: 'local:production', source: 'local', target: 'production', status: 'available' },
+ { id: 'local:staging', source: 'local', target: 'staging', status: 'available' },
+ { id: 'production:staging', source: 'production', target: 'staging', status: 'available' },
+ ] );
+ } );
+
+ it( 'groups production and staging by shared local site when explicit staging metadata is absent', () => {
+ const localSite = createLocalSite();
+ const productionSite = createSyncSite( {
+ id: 101,
+ localSiteId: localSite.id,
+ syncSupport: 'already-connected',
+ } );
+ const stagingSite = createSyncSite( {
+ id: 202,
+ localSiteId: localSite.id,
+ name: 'Auro Atelier Staging',
+ isStaging: true,
+ syncSupport: 'already-connected',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ productionSite, stagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ production: { siteId: 101 },
+ staging: { siteId: 202 },
+ },
+ } );
+ } );
+
+ it( 'does not group production and staging by name alone', () => {
+ const productionSite = createSyncSite( {
+ id: 101,
+ name: 'Auro Atelier',
+ } );
+ const stagingSite = createSyncSite( {
+ id: 202,
+ name: 'Auro Atelier Staging',
+ isStaging: true,
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ wpcomSites: [ productionSite, stagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 2 );
+ expect( workspaces.map( ( workspace ) => workspace.id ) ).toEqual( [
+ 'studio-workspace:wpcom:101',
+ 'studio-workspace:wpcom:202',
+ ] );
+ } );
+
+ it( 'groups connected staging when WP.com staging details are missing', () => {
+ const localSite = createLocalSite();
+ const productionSite = createSyncSite( {
+ id: 101,
+ stagingSiteIds: [ 202 ],
+ } );
+ const connectedStagingSite = createSyncSite( {
+ id: 202,
+ localSiteId: localSite.id,
+ name: 'Auro Atelier Staging',
+ url: 'https://auro-staging.example',
+ isStaging: true,
+ productionSiteId: 101,
+ syncSupport: 'already-connected',
+ lastPullTimestamp: '2026-05-14T12:00:00.000Z',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ productionSite ],
+ connectedSites: [ connectedStagingSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ] ).toMatchObject( {
+ id: 'studio-workspace:wpcom:101',
+ targets: {
+ local: { siteId: 'local-site-id' },
+ production: { siteId: 101 },
+ staging: {
+ siteId: 202,
+ site: expect.objectContaining( {
+ localSiteId: localSite.id,
+ lastPullTimestamp: '2026-05-14T12:00:00.000Z',
+ } ),
+ },
+ },
+ } );
+ } );
+
+ it( 'does not duplicate a local-backed workspace', () => {
+ const localSite = createLocalSite();
+ const productionSite = createSyncSite( {
+ id: 101,
+ localSiteId: localSite.id,
+ syncSupport: 'already-connected',
+ } );
+
+ const workspaces = buildStudioWorkspaces( {
+ localSites: [ localSite ],
+ wpcomSites: [ productionSite ],
+ connectedSites: [ productionSite ],
+ } );
+
+ expect( workspaces ).toHaveLength( 1 );
+ expect( workspaces[ 0 ].targets.local?.siteId ).toBe( localSite.id );
+ expect( workspaces[ 0 ].targets.production?.siteId ).toBe( productionSite.id );
+ } );
+} );
diff --git a/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts
new file mode 100644
index 0000000000..1db139c02f
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/build-studio-workspaces.ts
@@ -0,0 +1,300 @@
+import { sortSites } from '@studio/common/lib/sort-sites';
+import type { SyncSite } from '@studio/common/types/sync';
+import type {
+ BuildStudioWorkspacesInput,
+ RemoteTarget,
+ StudioWorkspace,
+ WorkspaceSyncLink,
+ WorkspaceTargetId,
+} from 'src/modules/workspaces/types';
+
+type WorkspaceGroup = {
+ remoteSites: SyncSite[];
+ localSiteIds: Set< string >;
+ productionSiteId?: number;
+};
+
+const createProductionGroupKey = ( productionSiteId: number ) => `production:${ productionSiteId }`;
+
+const createLocalGroupKey = ( localSiteId: string ) => `local:${ localSiteId }`;
+
+const createRemoteGroupKey = ( remoteSiteId: number ) => `remote:${ remoteSiteId }`;
+
+export function createStudioWorkspaceId( {
+ productionSiteId,
+ localSiteId,
+ stagingSiteId,
+}: {
+ productionSiteId?: number;
+ localSiteId?: string;
+ stagingSiteId?: number;
+} ) {
+ if ( productionSiteId ) {
+ return `studio-workspace:wpcom:${ productionSiteId }`;
+ }
+
+ if ( localSiteId ) {
+ return `studio-workspace:local:${ localSiteId }`;
+ }
+
+ return `studio-workspace:wpcom:${ stagingSiteId }`;
+}
+
+export function mergeWpcomSitesWithConnectedSites(
+ wpcomSites: SyncSite[] = [],
+ connectedSites: SyncSite[] = []
+) {
+ const connectedSitesById = new Map( connectedSites.map( ( site ) => [ site.id, site ] ) );
+ const mergedSites = wpcomSites.map( ( site ) => {
+ const connectedSite = connectedSitesById.get( site.id );
+ if ( ! connectedSite ) {
+ return site;
+ }
+
+ return {
+ ...connectedSite,
+ ...site,
+ localSiteId: connectedSite.localSiteId || site.localSiteId,
+ productionSiteId: site.productionSiteId ?? connectedSite.productionSiteId,
+ stagingSiteIds: site.stagingSiteIds ?? connectedSite.stagingSiteIds,
+ syncSupport:
+ connectedSite.syncSupport === 'already-connected'
+ ? connectedSite.syncSupport
+ : site.syncSupport,
+ lastPullTimestamp: connectedSite.lastPullTimestamp ?? site.lastPullTimestamp,
+ lastPushTimestamp: connectedSite.lastPushTimestamp ?? site.lastPushTimestamp,
+ };
+ } );
+ const mergedSiteIds = new Set( mergedSites.map( ( site ) => site.id ) );
+
+ return [ ...mergedSites, ...connectedSites.filter( ( site ) => ! mergedSiteIds.has( site.id ) ) ];
+}
+
+function createRemoteRelationshipIndex( remoteSites: SyncSite[] ) {
+ const productionSiteIdByStagingSiteId = new Map< number, number >();
+
+ remoteSites.forEach( ( site ) => {
+ if ( site.isStaging ) {
+ return;
+ }
+
+ site.stagingSiteIds?.forEach( ( stagingSiteId ) => {
+ if ( ! productionSiteIdByStagingSiteId.has( stagingSiteId ) ) {
+ productionSiteIdByStagingSiteId.set( stagingSiteId, site.id );
+ }
+ } );
+ } );
+
+ return productionSiteIdByStagingSiteId;
+}
+
+function getKnownProductionSiteId(
+ site: SyncSite,
+ productionSiteIdByStagingSiteId: Map< number, number >
+) {
+ if ( ! site.isStaging ) {
+ return site.id;
+ }
+
+ return site.productionSiteId ?? productionSiteIdByStagingSiteId.get( site.id );
+}
+
+function getOrCreateGroup( groups: Map< string, WorkspaceGroup >, key: string ) {
+ const existingGroup = groups.get( key );
+ if ( existingGroup ) {
+ return existingGroup;
+ }
+
+ const group = {
+ remoteSites: [],
+ localSiteIds: new Set< string >(),
+ };
+ groups.set( key, group );
+ return group;
+}
+
+function addRemoteSiteToGroup( group: WorkspaceGroup, site: SyncSite, productionSiteId?: number ) {
+ if ( ! group.remoteSites.some( ( remoteSite ) => remoteSite.id === site.id ) ) {
+ group.remoteSites.push( site );
+ }
+
+ if ( site.localSiteId ) {
+ group.localSiteIds.add( site.localSiteId );
+ }
+
+ if ( productionSiteId && ! group.productionSiteId ) {
+ group.productionSiteId = productionSiteId;
+ }
+}
+
+function createWorkspaceSyncLinks( targets: StudioWorkspace[ 'targets' ] ): WorkspaceSyncLink[] {
+ const links: Array< [ WorkspaceTargetId, WorkspaceTargetId ] > = [];
+
+ if ( targets.local && targets.production ) {
+ links.push( [ 'local', 'production' ] );
+ }
+
+ if ( targets.local && targets.staging ) {
+ links.push( [ 'local', 'staging' ] );
+ }
+
+ if ( targets.production && targets.staging ) {
+ links.push( [ 'production', 'staging' ] );
+ }
+
+ return links.map( ( [ source, target ] ) => ( {
+ id: `${ source }:${ target }`,
+ source,
+ target,
+ status: 'available',
+ } ) );
+}
+
+function createRemoteTarget( id: RemoteTarget[ 'id' ], site?: SyncSite ) {
+ if ( ! site ) {
+ return undefined;
+ }
+
+ return {
+ id,
+ kind: 'remote' as const,
+ siteId: site.id,
+ site,
+ };
+}
+
+function getFirstLocalSite(
+ localSiteIds: Set< string >,
+ localSitesById: Map< string, SiteDetails >,
+ localSiteOrder: string[]
+) {
+ const localSiteId = localSiteOrder.find( ( siteId ) => localSiteIds.has( siteId ) );
+ if ( localSiteId ) {
+ return localSitesById.get( localSiteId );
+ }
+
+ return undefined;
+}
+
+function createStudioWorkspace(
+ group: WorkspaceGroup,
+ localSitesById: Map< string, SiteDetails >,
+ localSiteOrder: string[]
+): StudioWorkspace | undefined {
+ const localSite = getFirstLocalSite( group.localSiteIds, localSitesById, localSiteOrder );
+ const productionSite = group.remoteSites
+ .filter( ( site ) => ! site.isStaging )
+ .sort( ( a, b ) => a.name.localeCompare( b.name, undefined, { numeric: true } ) )[ 0 ];
+ const stagingSite = group.remoteSites
+ .filter( ( site ) => site.isStaging )
+ .sort( ( a, b ) => a.name.localeCompare( b.name, undefined, { numeric: true } ) )[ 0 ];
+
+ if ( ! localSite && ! productionSite && ! stagingSite ) {
+ return undefined;
+ }
+
+ const targets: StudioWorkspace[ 'targets' ] = {};
+ if ( localSite ) {
+ targets.local = {
+ id: 'local',
+ kind: 'local',
+ siteId: localSite.id,
+ site: localSite,
+ };
+ }
+ const productionTarget = createRemoteTarget( 'production', productionSite );
+ if ( productionTarget ) {
+ targets.production = productionTarget;
+ }
+ const stagingTarget = createRemoteTarget( 'staging', stagingSite );
+ if ( stagingTarget ) {
+ targets.staging = stagingTarget;
+ }
+ const productionSiteId =
+ productionSite?.id ?? group.productionSiteId ?? stagingSite?.productionSiteId;
+ const workspace = {
+ id: createStudioWorkspaceId( {
+ productionSiteId,
+ localSiteId: localSite?.id,
+ stagingSiteId: stagingSite?.id,
+ } ),
+ name: localSite?.name ?? productionSite?.name ?? stagingSite?.name ?? '',
+ sortOrder: localSite?.sortOrder,
+ targets,
+ syncLinks: createWorkspaceSyncLinks( targets ),
+ activity: {
+ status: 'idle' as const,
+ },
+ };
+
+ return workspace;
+}
+
+export function buildStudioWorkspaces( {
+ localSites = [],
+ wpcomSites = [],
+ connectedSites = [],
+}: BuildStudioWorkspacesInput ): StudioWorkspace[] {
+ const remoteSites = mergeWpcomSitesWithConnectedSites( wpcomSites, connectedSites );
+ const localSitesById = new Map( localSites.map( ( site ) => [ site.id, site ] ) );
+ const localSiteOrder = localSites.map( ( site ) => site.id );
+ const productionSiteIdByStagingSiteId = createRemoteRelationshipIndex( remoteSites );
+ const groups = new Map< string, WorkspaceGroup >();
+ const groupedRemoteSiteIds = new Set< number >();
+ const groupKeysByLocalSiteId = new Map< string, string >();
+
+ remoteSites.forEach( ( site ) => {
+ const productionSiteId = getKnownProductionSiteId( site, productionSiteIdByStagingSiteId );
+ if ( ! productionSiteId ) {
+ return;
+ }
+
+ const groupKey = createProductionGroupKey( productionSiteId );
+ const group = getOrCreateGroup( groups, groupKey );
+ addRemoteSiteToGroup( group, site, productionSiteId );
+ groupedRemoteSiteIds.add( site.id );
+
+ if ( site.localSiteId ) {
+ groupKeysByLocalSiteId.set( site.localSiteId, groupKey );
+ }
+ } );
+
+ remoteSites.forEach( ( site ) => {
+ if ( groupedRemoteSiteIds.has( site.id ) ) {
+ return;
+ }
+
+ const groupKey =
+ ( site.localSiteId && groupKeysByLocalSiteId.get( site.localSiteId ) ) ||
+ ( site.localSiteId
+ ? createLocalGroupKey( site.localSiteId )
+ : createRemoteGroupKey( site.id ) );
+ const group = getOrCreateGroup( groups, groupKey );
+ addRemoteSiteToGroup( group, site );
+ groupedRemoteSiteIds.add( site.id );
+
+ if ( site.localSiteId ) {
+ groupKeysByLocalSiteId.set( site.localSiteId, groupKey );
+ }
+ } );
+
+ const localSiteIdsWithRemoteTargets = new Set< string >();
+ groups.forEach( ( group ) => {
+ group.localSiteIds.forEach( ( siteId ) => localSiteIdsWithRemoteTargets.add( siteId ) );
+ } );
+
+ localSites.forEach( ( site ) => {
+ if ( localSiteIdsWithRemoteTargets.has( site.id ) ) {
+ return;
+ }
+
+ const group = getOrCreateGroup( groups, createLocalGroupKey( site.id ) );
+ group.localSiteIds.add( site.id );
+ } );
+
+ return sortSites(
+ Array.from( groups.values() )
+ .map( ( group ) => createStudioWorkspace( group, localSitesById, localSiteOrder ) )
+ .filter( ( workspace ): workspace is StudioWorkspace => Boolean( workspace ) )
+ );
+}
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/api.ts b/apps/studio/src/modules/workspaces/lib/dolly/api.ts
new file mode 100644
index 0000000000..888d862f3f
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/api.ts
@@ -0,0 +1,463 @@
+import {
+ WORKSPACE_DOLLY_AGENT_ID,
+ WORKSPACE_DOLLY_HISTORY_BOT_ID,
+ WORKSPACE_DOLLY_HISTORY_CHAT_ITEMS_PER_PAGE,
+ WORKSPACE_DOLLY_HISTORY_MAX_PAGES,
+ WORKSPACE_DOLLY_HISTORY_SUMMARY_ITEMS_PER_PAGE,
+ WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS,
+ type WorkspaceDollyConversationState,
+ type WorkspaceDollyHistoryChat,
+ type WorkspaceDollyHistoryMessage,
+ type WorkspaceDollyHistorySummary,
+ type WorkspaceDollyWorkspaceDescriptor,
+} from 'src/modules/workspaces/lib/dolly/types';
+import {
+ extractBackendSelectedSiteIdFromRecord,
+ getFlexibleNumberValue,
+ getStringFromRecord,
+ isRecord,
+ normalizeDollySessionId,
+} from 'src/modules/workspaces/lib/dolly/utils';
+import { generateMessage, type Message as MessageType } from 'src/stores/chat-slice';
+import type { WPCOM } from 'wpcom/types';
+
+export const createWpcomRequest = < T >(
+ description: string,
+ timeoutMs: number,
+ executor: ( resolve: ( data: T ) => void, reject: ( error: Error ) => void ) => void
+): Promise< T > =>
+ new Promise( ( resolve, reject ) => {
+ let isSettled = false;
+ const timeout = setTimeout( () => {
+ if ( isSettled ) {
+ return;
+ }
+ isSettled = true;
+ reject( new Error( `${ description } timed out.` ) );
+ }, timeoutMs );
+ const settle = ( callback: () => void ) => {
+ if ( isSettled ) {
+ return;
+ }
+ isSettled = true;
+ clearTimeout( timeout );
+ callback();
+ };
+
+ try {
+ executor(
+ ( data ) => settle( () => resolve( data ) ),
+ ( error ) => settle( () => reject( error ) )
+ );
+ } catch ( error ) {
+ settle( () => reject( error instanceof Error ? error : new Error( description ) ) );
+ }
+ } );
+
+export const wpcomGet = async < T >(
+ client: WPCOM,
+ path: string,
+ timeoutMs = WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS
+): Promise< T > =>
+ createWpcomRequest< T >( `Dolly request to ${ path }`, timeoutMs, ( resolve, reject ) => {
+ void client.req.get(
+ {
+ path,
+ apiNamespace: 'wpcom/v2',
+ },
+ ( error: Error | null, data: unknown ) => {
+ if ( error ) {
+ reject( error );
+ return;
+ }
+ resolve( data as T );
+ }
+ );
+ } );
+
+export const extractWorkspaceDollyHistoryEntries = (
+ response: unknown
+): Record< string, unknown >[] => {
+ if ( Array.isArray( response ) ) {
+ return response.filter( isRecord );
+ }
+ if ( ! isRecord( response ) ) {
+ return [];
+ }
+ for ( const key of [ 'chats', 'conversations', 'data' ] ) {
+ const value = response[ key ];
+ if ( Array.isArray( value ) ) {
+ return value.filter( isRecord );
+ }
+ }
+ return [];
+};
+
+export const parseWorkspaceDollyHistoryDate = ( value: unknown ): number | undefined => {
+ if ( typeof value !== 'string' || ! value.trim() ) {
+ return undefined;
+ }
+
+ const trimmedValue = value.trim();
+ if ( /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test( trimmedValue ) ) {
+ const parsedUtcDate = Date.parse( `${ trimmedValue.replace( ' ', 'T' ) }Z` );
+ return Number.isFinite( parsedUtcDate ) ? parsedUtcDate : undefined;
+ }
+
+ const parsedDate = Date.parse( trimmedValue );
+ if ( Number.isFinite( parsedDate ) ) {
+ return parsedDate;
+ }
+
+ const parsedUtcDate = Date.parse( `${ trimmedValue.replace( ' ', 'T' ) }Z` );
+ return Number.isFinite( parsedUtcDate ) ? parsedUtcDate : undefined;
+};
+
+export const visibleWorkspaceDollyHistoryMessageText = ( value: string ) => {
+ const trimmedValue = value.trim();
+ if ( ! trimmedValue.startsWith( 'Local workspace context:' ) ) {
+ return trimmedValue;
+ }
+
+ const marker = '\nUser message:\n';
+ const markerIndex = trimmedValue.lastIndexOf( marker );
+ if ( markerIndex === -1 ) {
+ return trimmedValue;
+ }
+
+ const visibleText = trimmedValue.slice( markerIndex + marker.length ).trim();
+ return visibleText || trimmedValue;
+};
+
+export const parseWorkspaceDollyHistoryMessage = (
+ value: unknown
+): WorkspaceDollyHistoryMessage | undefined => {
+ if ( ! isRecord( value ) ) {
+ return undefined;
+ }
+
+ const rawContent = getStringFromRecord( value, [ 'content', 'text', 'message' ] );
+ if ( ! rawContent ) {
+ return undefined;
+ }
+
+ const rawRole = getStringFromRecord( value, [ 'role' ] )?.toLowerCase();
+ const role =
+ rawRole === 'user'
+ ? 'user'
+ : rawRole === 'bot' || rawRole === 'assistant' || rawRole === 'agent'
+ ? 'assistant'
+ : undefined;
+ if ( ! role ) {
+ return undefined;
+ }
+
+ const content =
+ role === 'user' ? visibleWorkspaceDollyHistoryMessageText( rawContent ) : rawContent.trim();
+ if ( ! content ) {
+ return undefined;
+ }
+
+ return {
+ content,
+ role,
+ createdAt: parseWorkspaceDollyHistoryDate( value.created_at ?? value.createdAt ) ?? Date.now(),
+ messageApiId: getFlexibleNumberValue( value, [ 'message_id', 'messageID', 'id' ] ),
+ };
+};
+
+export const createWorkspaceDollyMessagesFromHistory = (
+ messages: WorkspaceDollyHistoryMessage[]
+) =>
+ messages.map(
+ ( message, index ) =>
+ ( {
+ ...generateMessage( message.content, message.role, index, undefined, message.messageApiId ),
+ createdAt: message.createdAt,
+ } ) as MessageType
+ );
+
+const createWorkspaceDollyHistoryMessageKey = ( message: WorkspaceDollyHistoryMessage ) =>
+ message.messageApiId !== undefined
+ ? `id:${ message.messageApiId }`
+ : `fallback:${ message.createdAt }:${ message.role }:${ message.content }`;
+
+const deduplicateWorkspaceDollyHistoryMessages = ( messages: WorkspaceDollyHistoryMessage[] ) => {
+ const seenMessages = new Set< string >();
+ return [ ...messages ]
+ .sort( ( first, second ) => first.createdAt - second.createdAt )
+ .filter( ( message ) => {
+ const key = createWorkspaceDollyHistoryMessageKey( message );
+ if ( seenMessages.has( key ) ) {
+ return false;
+ }
+ seenMessages.add( key );
+ return true;
+ } );
+};
+
+const createWorkspaceDollyHistoryFallbackMessages = ( summary: WorkspaceDollyHistorySummary ) =>
+ [ summary.firstMessage, summary.lastMessage ]
+ .map( parseWorkspaceDollyHistoryMessage )
+ .filter( ( message ): message is WorkspaceDollyHistoryMessage => Boolean( message ) );
+
+export const parseWorkspaceDollyHistorySummary = (
+ value: unknown
+): WorkspaceDollyHistorySummary | undefined => {
+ if ( ! isRecord( value ) ) {
+ return undefined;
+ }
+
+ const chatId = getFlexibleNumberValue( value, [ 'chat_id', 'chatID', 'id' ] );
+ if ( ! chatId ) {
+ return undefined;
+ }
+
+ return {
+ chatId,
+ sessionId: getStringFromRecord( value, [ 'session_id', 'sessionId' ] ),
+ siteId: extractBackendSelectedSiteIdFromRecord( value ),
+ createdAt: parseWorkspaceDollyHistoryDate( value.created_at ?? value.createdAt ),
+ firstMessage: isRecord( value.first_message ) ? value.first_message : undefined,
+ lastMessage: isRecord( value.last_message ) ? value.last_message : undefined,
+ };
+};
+
+export const parseWorkspaceDollyHistoryChat = (
+ value: unknown,
+ summary: WorkspaceDollyHistorySummary,
+ includeFallbackMessages = true
+): WorkspaceDollyHistoryChat | undefined => {
+ if ( ! isRecord( value ) ) {
+ return undefined;
+ }
+
+ const chatId = getFlexibleNumberValue( value, [ 'chat_id', 'chatID', 'id' ] ) ?? summary.chatId;
+ const rawMessages = Array.isArray( value.messages ) ? value.messages : [];
+ const messages = rawMessages
+ .map( parseWorkspaceDollyHistoryMessage )
+ .filter( ( message ): message is WorkspaceDollyHistoryMessage => Boolean( message ) );
+ const fallbackMessages = includeFallbackMessages
+ ? createWorkspaceDollyHistoryFallbackMessages( summary )
+ : [];
+
+ return {
+ chatId,
+ sessionId: getStringFromRecord( value, [ 'session_id', 'sessionId' ] ) ?? summary.sessionId,
+ siteId: extractBackendSelectedSiteIdFromRecord( value ) ?? summary.siteId,
+ createdAt:
+ parseWorkspaceDollyHistoryDate( value.created_at ?? value.createdAt ) ?? summary.createdAt,
+ messages: messages.length > 0 ? messages : fallbackMessages,
+ };
+};
+
+export const createWorkspaceDollyConversationStateFromHistoryItems = (
+ workspace: WorkspaceDollyWorkspaceDescriptor,
+ historyItems: Array< { summary: WorkspaceDollyHistorySummary; chat?: WorkspaceDollyHistoryChat } >
+): WorkspaceDollyConversationState | undefined => {
+ const sortedHistoryItems = [ ...historyItems ].sort(
+ ( first, second ) => ( second.summary.createdAt ?? 0 ) - ( first.summary.createdAt ?? 0 )
+ );
+ const latestHistoryItem = sortedHistoryItems[ 0 ];
+ if ( ! latestHistoryItem ) {
+ return undefined;
+ }
+
+ const messages = deduplicateWorkspaceDollyHistoryMessages(
+ historyItems.flatMap( ( { summary, chat } ) =>
+ chat?.messages.length ? chat.messages : createWorkspaceDollyHistoryFallbackMessages( summary )
+ )
+ );
+ if ( messages.length === 0 ) {
+ return undefined;
+ }
+
+ const chatWithSession = sortedHistoryItems.find( ( { chat } ) => chat?.sessionId );
+ const lastUpdated =
+ messages[ messages.length - 1 ]?.createdAt ??
+ latestHistoryItem.chat?.createdAt ??
+ latestHistoryItem.summary.createdAt ??
+ Date.now();
+
+ return {
+ id: `wpcom:${ WORKSPACE_DOLLY_AGENT_ID }:${ latestHistoryItem.summary.chatId }`,
+ key: {
+ workspaceId: workspace.workspaceId,
+ agentId: WORKSPACE_DOLLY_AGENT_ID,
+ },
+ remoteChatId: latestHistoryItem.summary.chatId,
+ serverHydrationDisabled: false,
+ input: '',
+ messages: createWorkspaceDollyMessagesFromHistory( messages ),
+ sessionId: chatWithSession?.chat?.sessionId ?? latestHistoryItem.summary.sessionId,
+ lastUpdated,
+ };
+};
+
+export const fetchWorkspaceDollyHistorySummaries = async (
+ client: WPCOM,
+ itemsPerPage = WORKSPACE_DOLLY_HISTORY_SUMMARY_ITEMS_PER_PAGE,
+ maxPages = WORKSPACE_DOLLY_HISTORY_MAX_PAGES
+): Promise< WorkspaceDollyHistorySummary[] > => {
+ const summaries: WorkspaceDollyHistorySummary[] = [];
+ const seenChatIds = new Set< number >();
+
+ for ( let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1 ) {
+ const query = new URLSearchParams( {
+ truncation_method: 'last_message',
+ page_number: String( pageNumber ),
+ items_per_page: String( itemsPerPage ),
+ } );
+ const response = await wpcomGet< unknown >(
+ client,
+ `/ai/chats/${ WORKSPACE_DOLLY_HISTORY_BOT_ID }?${ query.toString() }`
+ );
+ const pageSummaries = extractWorkspaceDollyHistoryEntries( response )
+ .map( parseWorkspaceDollyHistorySummary )
+ .filter( ( summary ): summary is WorkspaceDollyHistorySummary => Boolean( summary ) );
+
+ for ( const summary of pageSummaries ) {
+ if ( seenChatIds.has( summary.chatId ) ) {
+ continue;
+ }
+ seenChatIds.add( summary.chatId );
+ summaries.push( summary );
+ }
+
+ if ( pageSummaries.length < itemsPerPage ) {
+ break;
+ }
+ }
+
+ return summaries;
+};
+
+export const fetchWorkspaceDollyHistoryChatPage = async (
+ client: WPCOM,
+ summary: WorkspaceDollyHistorySummary,
+ pageNumber: number,
+ itemsPerPage: number
+): Promise< WorkspaceDollyHistoryChat | undefined > => {
+ const query = new URLSearchParams( {
+ page_number: String( pageNumber ),
+ items_per_page: String( itemsPerPage ),
+ } );
+ const response = await wpcomGet< unknown >(
+ client,
+ `/ai/chat/${ WORKSPACE_DOLLY_HISTORY_BOT_ID }/${ summary.chatId }?${ query.toString() }`
+ );
+ return parseWorkspaceDollyHistoryChat( response, summary, pageNumber === 1 );
+};
+
+export const fetchWorkspaceDollyHistoryChat = async (
+ client: WPCOM,
+ summary: WorkspaceDollyHistorySummary,
+ itemsPerPage = WORKSPACE_DOLLY_HISTORY_CHAT_ITEMS_PER_PAGE,
+ maxPages = WORKSPACE_DOLLY_HISTORY_MAX_PAGES
+): Promise< WorkspaceDollyHistoryChat | undefined > => {
+ const messages: WorkspaceDollyHistoryMessage[] = [];
+ let mergedChat: WorkspaceDollyHistoryChat | undefined;
+
+ for ( let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1 ) {
+ const chatPage = await fetchWorkspaceDollyHistoryChatPage(
+ client,
+ summary,
+ pageNumber,
+ itemsPerPage
+ );
+ if ( ! chatPage ) {
+ break;
+ }
+
+ mergedChat = {
+ ...chatPage,
+ sessionId: mergedChat?.sessionId ?? chatPage.sessionId,
+ siteId: mergedChat?.siteId ?? chatPage.siteId,
+ createdAt: mergedChat?.createdAt ?? chatPage.createdAt,
+ messages,
+ };
+ messages.push( ...chatPage.messages );
+
+ if ( chatPage.messages.length < itemsPerPage ) {
+ break;
+ }
+ }
+
+ if ( ! mergedChat ) {
+ return undefined;
+ }
+
+ return {
+ ...mergedChat,
+ messages: deduplicateWorkspaceDollyHistoryMessages( messages ),
+ };
+};
+
+export const hydrateWorkspaceDollyConversationStates = async (
+ client: WPCOM,
+ workspace: WorkspaceDollyWorkspaceDescriptor,
+ preferredSessionId?: string
+): Promise< WorkspaceDollyConversationState[] > => {
+ const summaries = await fetchWorkspaceDollyHistorySummaries( client );
+ const normalizedPreferredSessionId = normalizeDollySessionId( preferredSessionId );
+ const groupedSummaries = new Map< string, WorkspaceDollyHistorySummary[] >();
+ const remoteSiteIds = new Set( workspace.remoteTargets.map( ( target ) => target.site.id ) );
+
+ for ( const summary of summaries ) {
+ const normalizedSessionId = normalizeDollySessionId( summary.sessionId );
+ const matchesPreferredSession =
+ normalizedPreferredSessionId &&
+ normalizedSessionId === normalizedPreferredSessionId &&
+ ( summary.siteId === undefined || remoteSiteIds.has( summary.siteId ) );
+
+ if (
+ summary.siteId !== undefined &&
+ ! remoteSiteIds.has( summary.siteId ) &&
+ ! matchesPreferredSession
+ ) {
+ continue;
+ }
+
+ const groupKey = normalizedSessionId
+ ? `session:${ normalizedSessionId }`
+ : `chat:${ summary.chatId }`;
+ groupedSummaries.set( groupKey, [ ...( groupedSummaries.get( groupKey ) ?? [] ), summary ] );
+ }
+
+ const hydratedConversationStates: WorkspaceDollyConversationState[] = [];
+
+ for ( const groupSummaries of groupedSummaries.values() ) {
+ const sortedGroupSummaries = [ ...groupSummaries ].sort(
+ ( first, second ) => ( second.createdAt ?? 0 ) - ( first.createdAt ?? 0 )
+ );
+ const historyItems: Array< {
+ summary: WorkspaceDollyHistorySummary;
+ chat?: WorkspaceDollyHistoryChat;
+ } > = [];
+
+ for ( const summary of sortedGroupSummaries ) {
+ try {
+ historyItems.push( {
+ summary,
+ chat: await fetchWorkspaceDollyHistoryChat( client, summary ),
+ } );
+ } catch ( error ) {
+ console.error( error );
+ historyItems.push( { summary } );
+ }
+ }
+
+ const conversationState = createWorkspaceDollyConversationStateFromHistoryItems(
+ workspace,
+ historyItems
+ );
+
+ if ( conversationState ) {
+ hydratedConversationStates.push( conversationState );
+ }
+ }
+
+ return hydratedConversationStates.sort(
+ ( first, second ) => second.lastUpdated - first.lastUpdated
+ );
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/media.test.ts b/apps/studio/src/modules/workspaces/lib/dolly/media.test.ts
new file mode 100644
index 0000000000..4617e23b24
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/media.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest';
+import {
+ createWorkspaceDollyVisibleMessage,
+ isWorkspaceDollyRenderableImageLinkUrl,
+ isWorkspaceDollyRenderableImageUrl,
+} from 'src/modules/workspaces/lib/dolly/media';
+
+describe( 'workspace Dolly media helpers', () => {
+ const customSiteUrl = 'https://bravely-so-donut.commerce-garden.com';
+
+ it( 'renders uploaded media as image markdown', () => {
+ expect(
+ createWorkspaceDollyVisibleMessage(
+ 'Please look at this.',
+ [
+ {
+ name: 'Dapper Dog [draft]',
+ url: 'https://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog.png',
+ },
+ ],
+ 1
+ )
+ ).toBe(
+ 'Please look at this.\n\n![Dapper Dog \\[draft\\]](https://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog.png)'
+ );
+ } );
+
+ it( 'allows only local previews and active-site image URLs', () => {
+ expect( isWorkspaceDollyRenderableImageUrl( 'data:image/png;base64,abc' ) ).toBe( true );
+ expect( isWorkspaceDollyRenderableImageUrl( 'blob:http://localhost/image' ) ).toBe( true );
+ expect(
+ isWorkspaceDollyRenderableImageUrl(
+ 'https://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog.png',
+ customSiteUrl
+ )
+ ).toBe( true );
+ expect(
+ isWorkspaceDollyRenderableImageUrl(
+ 'https://horsing-around.files.wordpress.com/2026/05/dapper-dog.png',
+ customSiteUrl
+ )
+ ).toBe( false );
+ expect(
+ isWorkspaceDollyRenderableImageUrl( 'https://i0.wp.com/example.com/image.png', customSiteUrl )
+ ).toBe( false );
+ expect(
+ isWorkspaceDollyRenderableImageUrl( 'https://cdn.example.com/image.png', customSiteUrl )
+ ).toBe( false );
+ expect(
+ isWorkspaceDollyRenderableImageUrl(
+ 'http://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog.png',
+ customSiteUrl
+ )
+ ).toBe( false );
+ } );
+
+ it( 'only upgrades active-site direct image links to inline previews', () => {
+ expect(
+ isWorkspaceDollyRenderableImageLinkUrl(
+ 'https://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog.webp',
+ customSiteUrl
+ )
+ ).toBe( true );
+ expect(
+ isWorkspaceDollyRenderableImageLinkUrl(
+ 'https://bravely-so-donut.commerce-garden.com/wp-content/uploads/2026/05/dapper-dog',
+ customSiteUrl
+ )
+ ).toBe( false );
+ expect(
+ isWorkspaceDollyRenderableImageLinkUrl( 'https://cdn.example.com/image.png', customSiteUrl )
+ ).toBe( false );
+ } );
+} );
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/media.tsx b/apps/studio/src/modules/workspaces/lib/dolly/media.tsx
new file mode 100644
index 0000000000..28af9e02a9
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/media.tsx
@@ -0,0 +1,300 @@
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import {
+ WORKSPACE_DOLLY_MEDIA_UPLOAD_URL_ORIGIN,
+ type WorkspaceDollyAgentImageUrl,
+ type WorkspaceDollyPendingImage,
+ type WorkspaceDollyUploadedImage,
+ type WorkspaceDollyVisibleImage,
+} from 'src/modules/workspaces/lib/dolly/types';
+
+export const createWorkspaceDollyImageUrl = (
+ image: WorkspaceDollyUploadedImage
+): WorkspaceDollyAgentImageUrl => ( {
+ url: image.url,
+ metadata: {
+ id: image.id,
+ url: image.url,
+ mimeType: image.mimeType,
+ name: image.name,
+ title: image.title ?? image.name,
+ fileName: image.fileName ?? image.name,
+ fileType: image.mimeType,
+ },
+} );
+
+export const WORKSPACE_DOLLY_IMAGE_PREVIEW_CLASS_NAME = 'block h-auto rounded-md object-contain';
+
+export const WORKSPACE_DOLLY_IMAGE_PREVIEW_STYLE = {
+ maxHeight: '320px',
+ maxWidth: 'min(100%, 520px)',
+};
+
+export const revokeWorkspaceDollyPendingImageUrls = ( images: WorkspaceDollyPendingImage[] ) => {
+ images.forEach( ( image ) => URL.revokeObjectURL( image.url ) );
+};
+
+export const readWorkspaceDollyFileAsDataUrl = ( file: File ) =>
+ new Promise< string >( ( resolve, reject ) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ if ( typeof reader.result === 'string' ) {
+ resolve( reader.result );
+ return;
+ }
+ reject( new Error( __( 'Unable to prepare image preview.' ) ) );
+ };
+ reader.onerror = () =>
+ reject( reader.error ?? new Error( __( 'Unable to prepare image preview.' ) ) );
+ reader.readAsDataURL( file );
+ } );
+
+export const createWorkspaceDollyPendingVisibleImages = async (
+ images: WorkspaceDollyPendingImage[]
+): Promise< WorkspaceDollyVisibleImage[] > =>
+ Promise.all(
+ images.map( async ( image ) => ( {
+ name: image.name ?? image.file.name,
+ url: image.dataUrl ?? ( await readWorkspaceDollyFileAsDataUrl( image.file ) ),
+ } ) )
+ );
+
+export const getWorkspaceDollySiteImageHostname = ( siteUrl: string ) => {
+ try {
+ return new URL( siteUrl ).hostname.toLowerCase();
+ } catch {
+ return undefined;
+ }
+};
+
+export const isWorkspaceDollyRenderableImageUrl = ( src?: string, siteUrl?: string ) => {
+ if ( ! src ) {
+ return false;
+ }
+
+ if ( src.startsWith( 'data:' ) || src.startsWith( 'blob:' ) ) {
+ return true;
+ }
+
+ try {
+ const url = new URL( src );
+ if ( url.protocol !== 'https:' ) {
+ return false;
+ }
+
+ const siteHostname = siteUrl ? getWorkspaceDollySiteImageHostname( siteUrl ) : undefined;
+ return Boolean( siteHostname && url.hostname.toLowerCase() === siteHostname );
+ } catch {
+ return false;
+ }
+};
+
+export const isWorkspaceDollyRenderableImageLinkUrl = ( href?: string, siteUrl?: string ) => {
+ if ( ! isWorkspaceDollyRenderableImageUrl( href, siteUrl ) ) {
+ return false;
+ }
+
+ try {
+ const pathname = new URL( href as string ).pathname.toLowerCase();
+ return /\.(avif|gif|jpe?g|png|webp)$/.test( pathname );
+ } catch {
+ return false;
+ }
+};
+
+const getRawStringValue = ( value: unknown ) => ( typeof value === 'string' ? value : undefined );
+
+const getNumberValue = ( value: unknown ) => {
+ if ( typeof value === 'number' ) {
+ return value;
+ }
+ if ( typeof value === 'string' ) {
+ const parsedValue = Number.parseInt( value, 10 );
+ return Number.isNaN( parsedValue ) ? undefined : parsedValue;
+ }
+ return undefined;
+};
+
+const getFileNameFromUrl = ( url: string ) => {
+ try {
+ return decodeURIComponent( new URL( url ).pathname.split( '/' ).filter( Boolean ).pop() ?? '' );
+ } catch {
+ return '';
+ }
+};
+
+const removeFileExtension = ( fileName: string ) => fileName.replace( /\.[^.]+$/, '' );
+
+const getWorkspaceDollyUploadErrorMessage = ( data: unknown ) => {
+ if ( ! data || typeof data !== 'object' ) {
+ return undefined;
+ }
+
+ const errors = ( data as { errors?: unknown } ).errors;
+ if ( ! Array.isArray( errors ) ) {
+ return undefined;
+ }
+
+ return errors
+ .map( ( error ) => {
+ if ( ! error || typeof error !== 'object' ) {
+ return undefined;
+ }
+ return getRawStringValue( ( error as { message?: unknown } ).message );
+ } )
+ .find( Boolean );
+};
+
+export const throwIfWorkspaceDollyRequestAborted = ( abortSignal?: AbortSignal ) => {
+ if ( abortSignal?.aborted ) {
+ if ( typeof DOMException !== 'undefined' ) {
+ throw new DOMException( 'Dolly request was stopped.', 'AbortError' );
+ }
+ const error = new Error( 'Dolly request was stopped.' );
+ error.name = 'AbortError';
+ throw error;
+ }
+};
+
+const normalizeWorkspaceDollyUploadedImage = (
+ rawMedia: unknown,
+ originalImage: WorkspaceDollyPendingImage
+): WorkspaceDollyUploadedImage | undefined => {
+ if ( ! rawMedia || typeof rawMedia !== 'object' ) {
+ return undefined;
+ }
+
+ const media = rawMedia as Record< string, unknown >;
+ const id = getNumberValue( media.ID ) ?? getNumberValue( media.id ) ?? 0;
+ const url = getRawStringValue( media.URL ) ?? getRawStringValue( media.url ) ?? '';
+ const mimeType =
+ getRawStringValue( media.mime_type ) ||
+ getRawStringValue( media.mimeType ) ||
+ originalImage.file.type ||
+ 'application/octet-stream';
+ const fileName = getRawStringValue( media.file ) ?? originalImage.file.name;
+ const title =
+ getRawStringValue( media.title ) ??
+ getRawStringValue( media.name ) ??
+ removeFileExtension( fileName );
+ const name = title || getFileNameFromUrl( url ) || originalImage.file.name;
+
+ if ( id <= 0 || ! url.trim() ) {
+ return undefined;
+ }
+
+ return {
+ id,
+ url,
+ name,
+ mimeType,
+ fileName,
+ title,
+ };
+};
+
+export const uploadWorkspaceDollyImages = async (
+ siteId: number,
+ images: WorkspaceDollyPendingImage[],
+ abortSignal?: AbortSignal
+): Promise< WorkspaceDollyUploadedImage[] > => {
+ if ( images.length === 0 ) {
+ return [];
+ }
+
+ const token = await getIpcApi().getAuthenticationToken();
+ throwIfWorkspaceDollyRequestAborted( abortSignal );
+ if ( ! token?.accessToken ) {
+ throw new Error( __( 'Log in to WordPress.com before uploading images.' ) );
+ }
+
+ const formData = new FormData();
+ images.forEach( ( image, index ) => {
+ formData.append( 'media[]', image.file, image.file.name );
+ formData.append( `attrs[${ index }][title]`, removeFileExtension( image.file.name ) );
+ } );
+
+ const response = await fetch(
+ `${ WORKSPACE_DOLLY_MEDIA_UPLOAD_URL_ORIGIN }/sites/${ siteId }/media/new`,
+ {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${ token.accessToken }`,
+ },
+ body: formData,
+ signal: abortSignal,
+ }
+ );
+ const data: unknown = await response.json().catch( () => undefined );
+
+ if ( ! response.ok ) {
+ throw new Error(
+ getWorkspaceDollyUploadErrorMessage( data ) ??
+ __( 'The image upload failed. Please try again.' )
+ );
+ }
+
+ const media =
+ data && typeof data === 'object' ? ( data as { media?: unknown } ).media : undefined;
+ if ( ! Array.isArray( media ) ) {
+ throw new Error( __( 'The image upload response was missing media details.' ) );
+ }
+
+ const uploadedImages = media
+ .map( ( rawMedia, index ) => normalizeWorkspaceDollyUploadedImage( rawMedia, images[ index ] ) )
+ .filter( ( image ): image is WorkspaceDollyUploadedImage => Boolean( image ) );
+
+ if ( uploadedImages.length !== images.length ) {
+ throw new Error( __( 'The image upload response was missing attachment metadata.' ) );
+ }
+
+ return uploadedImages;
+};
+
+const escapeMarkdownAltText = ( value: string ) => value.replace( /[[\]\\]/g, '\\$&' );
+
+export const createWorkspaceDollyVisibleMessage = (
+ message: string,
+ images: WorkspaceDollyVisibleImage[],
+ fallbackImageCount: number
+) => {
+ const imageMarkdown = images
+ .map( ( image ) => `` )
+ .join( '\n' );
+ const attachmentLabel =
+ images.length > 0
+ ? imageMarkdown
+ : fallbackImageCount > 0
+ ? sprintf(
+ _n( '%d image attached', '%d images attached', fallbackImageCount ),
+ fallbackImageCount
+ )
+ : undefined;
+
+ return [ message, attachmentLabel ].filter( Boolean ).join( '\n\n' );
+};
+
+export const createWorkspaceDollyImagePrompt = ( imageCount: number ) =>
+ imageCount === 1
+ ? __( 'Please look at the attached image.' )
+ : __( 'Please look at the attached images.' );
+
+export const WorkspaceDollyOptimisticImages = ( {
+ images = [],
+}: {
+ images?: WorkspaceDollyVisibleImage[];
+} ) => (
+
+ { images.map( ( image ) => (
+
+ ) ) }
+
+);
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/preview.ts b/apps/studio/src/modules/workspaces/lib/dolly/preview.ts
new file mode 100644
index 0000000000..96f1016424
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/preview.ts
@@ -0,0 +1,462 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { resolveWorkspacePreviewUrl } from 'src/modules/workspaces/components/workspace-preview';
+import {
+ WORKSPACE_DOLLY_AGENT_URL_ORIGIN,
+ WORKSPACE_DOLLY_FRONTEND_ABILITIES,
+ WORKSPACE_DOLLY_HISTORY_CLIENT,
+ WORKSPACE_DOLLY_PREVIEW_TOOL_ID,
+ WORKSPACE_DOLLY_REFRESH_PREVIEW_TOOL_ID,
+ type WorkspaceDollyPreviewContext,
+ type WorkspaceDollySiteAssociationContext,
+} from 'src/modules/workspaces/lib/dolly/types';
+import { hasHttpProtocol } from 'src/modules/workspaces/lib/dolly/utils';
+import type { Ability, ContextProvider } from '@automattic/agenttic-client';
+import type { WorkspacePreviewState } from 'src/modules/workspaces/components/workspace-preview';
+import type {
+ RemoteTarget,
+ StudioWorkspace,
+ WorkspaceTargetId,
+} from 'src/modules/workspaces/types';
+
+type OpenPreviewOptions = {
+ forceReload?: boolean;
+};
+
+type PreviewAbilityContext = {
+ targets: PreviewAbilityTarget[];
+ previewState: WorkspacePreviewState;
+ openPreview: (
+ targetId: WorkspaceTargetId,
+ pathOrUrl?: string,
+ options?: OpenPreviewOptions
+ ) => void;
+};
+
+export type PreviewAbilityTarget = {
+ targetId: WorkspaceTargetId;
+ siteId?: number | string;
+ siteName: string;
+ siteUrl: string;
+ isProduction?: boolean;
+};
+
+const getStringValue = (
+ record: Record< string, unknown >,
+ possibleKeys: string[]
+): string | undefined => {
+ for ( const key of possibleKeys ) {
+ const value = record[ key ];
+ if ( typeof value === 'string' && value.trim() ) {
+ return value.trim();
+ }
+ }
+};
+
+const getBooleanValue = (
+ record: Record< string, unknown >,
+ possibleKeys: string[]
+): boolean | undefined => {
+ for ( const key of possibleKeys ) {
+ const value = record[ key ];
+ if ( typeof value === 'boolean' ) {
+ return value;
+ }
+ if ( typeof value === 'string' ) {
+ const normalizedValue = value.trim().toLowerCase();
+ if ( normalizedValue === 'true' ) {
+ return true;
+ }
+ if ( normalizedValue === 'false' ) {
+ return false;
+ }
+ }
+ }
+};
+
+const getTargetIdValue = ( record: Record< string, unknown > ): WorkspaceTargetId | undefined => {
+ const targetId = getStringValue( record, [ 'targetId', 'target_id', 'target' ] );
+ if ( targetId === 'local' || targetId === 'staging' || targetId === 'production' ) {
+ return targetId;
+ }
+};
+
+const shouldForcePreviewReload = ( toolArguments: Record< string, unknown > ): boolean =>
+ getBooleanValue( toolArguments, [
+ 'siteChanged',
+ 'site_changed',
+ 'previewNeedsRefresh',
+ 'preview_needs_refresh',
+ ] ) === true;
+
+export const getNextWorkspaceDollyPreviewState = (
+ currentState: WorkspacePreviewState,
+ pathOrUrl = '/',
+ { forceReload = false }: OpenPreviewOptions = {}
+): WorkspacePreviewState => {
+ const shouldLoad = forceReload || ! currentState.open || currentState.pathOrUrl !== pathOrUrl;
+
+ return {
+ ...currentState,
+ open: true,
+ pathOrUrl,
+ currentUrl: shouldLoad ? undefined : currentState.currentUrl,
+ canGoBack: shouldLoad ? false : currentState.canGoBack,
+ canGoForward: shouldLoad ? false : currentState.canGoForward,
+ reloadNonce: forceReload ? currentState.reloadNonce + 1 : currentState.reloadNonce,
+ navigationAction: undefined,
+ };
+};
+
+const createWorkspaceDollyPreviewAbility = (
+ callback: NonNullable< Ability[ 'callback' ] >
+): Ability => ( {
+ name: WORKSPACE_DOLLY_PREVIEW_TOOL_ID,
+ label: 'Preview URL',
+ description:
+ 'Open a web URL in the WordPress Studio side preview panel for an explicit workspace target.',
+ category: 'interface',
+ input_schema: {
+ type: 'object',
+ properties: {
+ targetId: {
+ type: 'string',
+ enum: [ 'local', 'staging', 'production' ],
+ description:
+ 'Required workspace target to preview. Use local when available for safest read-only inspection, staging before production for remote previews.',
+ },
+ url: {
+ type: 'string',
+ description:
+ 'The absolute http or https URL to preview. Studio also accepts paths relative to the requested workspace target, such as / or /wp-admin/.',
+ },
+ siteChanged: {
+ type: 'boolean',
+ description:
+ 'Set true only after changing the selected WordPress.com site so Studio refreshes the current preview.',
+ },
+ },
+ required: [ 'targetId', 'url' ],
+ },
+ output_schema: {
+ type: 'object',
+ properties: {
+ success: { type: 'boolean' },
+ url: { type: 'string' },
+ message: { type: 'string' },
+ },
+ },
+ meta: {
+ annotations: {
+ instructions:
+ 'Use when the user asks to open, show, inspect, preview, or keep a URL visible beside the chat.',
+ readonly: false,
+ destructive: false,
+ idempotent: true,
+ },
+ },
+ callback,
+} );
+
+const createWorkspaceDollyRefreshPreviewAbility = (
+ callback: NonNullable< Ability[ 'callback' ] >
+): Ability => ( {
+ name: WORKSPACE_DOLLY_REFRESH_PREVIEW_TOOL_ID,
+ label: 'Refresh Preview',
+ description:
+ 'Refresh the currently open WordPress Studio side preview panel after an explicit workspace target has changed.',
+ category: 'interface',
+ input_schema: {
+ type: 'object',
+ properties: {
+ targetId: {
+ type: 'string',
+ enum: [ 'local', 'staging', 'production' ],
+ description:
+ 'Required workspace target to refresh. Production should only be used after explicit user confirmation for production-impacting changes.',
+ },
+ url: {
+ type: 'string',
+ description:
+ 'Optional absolute http or https URL, or path relative to the requested workspace target.',
+ },
+ reason: {
+ type: 'string',
+ description: 'Short reason the preview needs to refresh.',
+ },
+ },
+ required: [ 'targetId' ],
+ },
+ output_schema: {
+ type: 'object',
+ properties: {
+ success: { type: 'boolean' },
+ refreshed: { type: 'boolean' },
+ url: { type: 'string' },
+ message: { type: 'string' },
+ },
+ },
+ meta: {
+ annotations: {
+ instructions:
+ 'Use immediately after successfully changing visible site content when clientContext.preview.isOpen is true.',
+ readonly: false,
+ destructive: false,
+ idempotent: true,
+ },
+ },
+ callback,
+} );
+
+export const createWorkspaceDollyPreviewAbilities = ( {
+ targets,
+ previewState,
+ openPreview,
+}: PreviewAbilityContext ): Ability[] => [
+ createWorkspaceDollyPreviewAbility( ( input: Record< string, unknown > ) => {
+ const targetId = getTargetIdValue( input );
+ const target = targets.find( ( candidate ) => candidate.targetId === targetId );
+ if ( ! target ) {
+ return {
+ success: false,
+ error: 'Preview needs a valid targetId: local, staging, or production.',
+ };
+ }
+
+ const requestedUrl = getStringValue( input, [ 'url', 'URL', 'uri', 'path' ] );
+ if ( ! requestedUrl ) {
+ return {
+ success: false,
+ error: 'Preview needs a valid URL or workspace target path.',
+ };
+ }
+
+ const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( target.siteUrl, requestedUrl );
+ openPreview( target.targetId, requestedUrl, {
+ forceReload: shouldForcePreviewReload( input ),
+ } );
+
+ return {
+ success: true,
+ url: normalizedUrl,
+ message: sprintf( __( 'Opened preview: %s' ), normalizedUrl ),
+ };
+ } ),
+ createWorkspaceDollyRefreshPreviewAbility( ( input: Record< string, unknown > ) => {
+ const targetId = getTargetIdValue( input );
+ const target = targets.find( ( candidate ) => candidate.targetId === targetId );
+ if ( ! target ) {
+ return {
+ success: false,
+ refreshed: false,
+ error: 'Refresh preview needs a valid targetId: local, staging, or production.',
+ };
+ }
+
+ const requestedUrl = getStringValue( input, [ 'url', 'URL', 'uri', 'path' ] );
+ const refreshUrl = requestedUrl || previewState.currentUrl || previewState.pathOrUrl || '/';
+ const normalizedUrl = normalizeWorkspaceDollyPreviewUrl( target.siteUrl, refreshUrl );
+
+ if ( ! previewState.open ) {
+ return {
+ success: true,
+ refreshed: false,
+ url: normalizedUrl,
+ message: __( 'Preview is hidden, so there was nothing to refresh.' ),
+ };
+ }
+
+ openPreview( target.targetId, refreshUrl, { forceReload: true } );
+
+ return {
+ success: true,
+ refreshed: true,
+ url: normalizedUrl,
+ message: sprintf( __( 'Refreshed preview: %s' ), normalizedUrl ),
+ };
+ } ),
+];
+
+export const createWorkspaceDollyPreviewContext = (
+ targetId: WorkspaceTargetId | undefined,
+ siteUrl: string,
+ previewState: WorkspacePreviewState,
+ siteId?: number | string
+): WorkspaceDollyPreviewContext => {
+ const openedURL = resolveWorkspacePreviewUrl( siteUrl, previewState.pathOrUrl );
+
+ return {
+ isOpen: previewState.open,
+ targetId,
+ siteId,
+ openedURL,
+ currentURL: previewState.currentUrl ?? openedURL,
+ isLoading: false,
+ };
+};
+
+export const createWorkspaceDollySiteAssociationContext = ( {
+ workspaceId,
+ workspace,
+ transportTarget,
+ activeTarget,
+ activeUrl,
+ targets,
+}: {
+ workspaceId: string;
+ workspace: StudioWorkspace;
+ transportTarget: RemoteTarget;
+ activeTarget?: PreviewAbilityTarget;
+ activeUrl?: string;
+ targets: PreviewAbilityTarget[];
+} ): WorkspaceDollySiteAssociationContext => ( {
+ status: 'workspace',
+ workspaceId,
+ transportTargetId: transportTarget.id,
+ transportWpcomSiteId: transportTarget.site.id,
+ transportWpcomSiteUrl: transportTarget.site.url,
+ activeTargetId: activeTarget?.targetId,
+ activeSiteId: activeTarget?.siteId,
+ activeSiteUrl: activeUrl ?? activeTarget?.siteUrl,
+ activeSiteBaseUrl: activeTarget?.siteUrl,
+ targets: targets.map( ( target ) => ( {
+ targetId: target.targetId,
+ siteId: target.siteId,
+ siteUrl: target.siteUrl,
+ isProduction: target.targetId === 'production',
+ } ) ),
+ instructions: `This WordPress Studio workspace is "${ workspace.name }". Local, staging, and production are capabilities inside the workspace, not separate chats. Every target-specific action must choose an explicit targetId. Ask when a requested change is ambiguous. Prefer local, then staging, then production for safe read-only defaults. Production-impacting actions require clear user confirmation before acting.`,
+} );
+
+export const createWorkspaceDollyClientContext = (
+ workspaceId: string,
+ workspace: StudioWorkspace,
+ transportTarget: RemoteTarget,
+ previewContext: WorkspaceDollyPreviewContext,
+ siteAssociation: WorkspaceDollySiteAssociationContext,
+ targets: PreviewAbilityTarget[]
+) => {
+ const activeTarget = targets.find( ( target ) => target.targetId === previewContext.targetId );
+ const activeTargetUrl = siteAssociation.activeSiteUrl ?? activeTarget?.siteUrl;
+
+ return {
+ constructorArguments: {
+ client: WORKSPACE_DOLLY_HISTORY_CLIENT,
+ },
+ selectedSiteId: transportTarget.site.id,
+ preview: previewContext,
+ studioSiteAssociation: siteAssociation,
+ frontendAbilities: WORKSPACE_DOLLY_FRONTEND_ABILITIES,
+ wpworkspace: {
+ appName: window.appGlobals?.appName ?? 'WordPress Studio',
+ currentActivity:
+ 'Working in one WordPress Studio workspace. Targets are explicit tool contexts, not separate chats.',
+ clientVersion: window.appGlobals?.appVersion,
+ workspace: {
+ id: workspaceId,
+ name: workspace.name,
+ activeTarget: activeTarget
+ ? {
+ targetId: activeTarget.targetId,
+ siteId: activeTarget.siteId,
+ name: activeTarget.siteName,
+ url: activeTargetUrl,
+ siteUrl: activeTarget.siteUrl,
+ currentUrl: previewContext.currentURL ?? activeTargetUrl,
+ isProduction: activeTarget.targetId === 'production',
+ }
+ : undefined,
+ targets: targets.map( ( target ) => ( {
+ targetId: target.targetId,
+ siteId: target.siteId,
+ name: target.siteName,
+ url: target.siteUrl,
+ isProduction: target.targetId === 'production',
+ } ) ),
+ },
+ selectedSite: {
+ id: transportTarget.site.id,
+ name: transportTarget.site.name,
+ url: transportTarget.site.url,
+ siteId: transportTarget.site.id,
+ kind: 'wpcom-site',
+ transportTargetId: transportTarget.id,
+ },
+ preview: previewContext,
+ studioSiteAssociation: siteAssociation,
+ frontendAbilities: WORKSPACE_DOLLY_FRONTEND_ABILITIES,
+ targetPolicy: {
+ required:
+ 'Every tool call that reads, previews, syncs, or mutates a site must include an explicit targetId.',
+ ambiguousChanges:
+ 'Ask the user which target to change unless the safest target is clearly local or staging.',
+ production:
+ 'Production-impacting actions require clear confirmation and should be called out visibly.',
+ },
+ previewRefreshPolicy: {
+ afterVisibleSiteChange:
+ 'When a successful action changes a visible target and preview.isOpen is true, call wpworkspace/refresh_preview with that explicit targetId before the final reply.',
+ hiddenPreviewBehavior:
+ 'Do not open a hidden preview just to auto-refresh. Use wpworkspace/preview only when the user asks to open or show a preview.',
+ },
+ },
+ };
+};
+
+export const createWorkspaceDollyContextProvider = (
+ workspaceId: string,
+ workspace: StudioWorkspace,
+ transportTarget: RemoteTarget,
+ previewContext: WorkspaceDollyPreviewContext,
+ siteAssociation: WorkspaceDollySiteAssociationContext,
+ targets: PreviewAbilityTarget[]
+): ContextProvider => ( {
+ getClientContext: () =>
+ createWorkspaceDollyClientContext(
+ workspaceId,
+ workspace,
+ transportTarget,
+ previewContext,
+ siteAssociation,
+ targets
+ ),
+} );
+
+export const createWorkspaceDollyAuthProvider =
+ () => async (): Promise< Record< string, string > > => {
+ const token = await getIpcApi().getAuthenticationToken();
+ return token?.accessToken ? { Authorization: `Bearer ${ token.accessToken }` } : {};
+ };
+
+export const createWorkspaceDollyAgentUrl = ( siteId: number ) =>
+ `${ WORKSPACE_DOLLY_AGENT_URL_ORIGIN }/sites/${ siteId }/ai/agent`;
+
+export const createWorkspaceDollyAgentManagerKey = ( workspaceId: string, siteId: number ) =>
+ `${ workspaceId }:${ siteId }:${ WORKSPACE_DOLLY_HISTORY_CLIENT }`;
+
+const isHttpUrl = ( value: string ) => {
+ try {
+ const url = new URL( value );
+ return hasHttpProtocol( url );
+ } catch {
+ return false;
+ }
+};
+
+export const normalizeWorkspaceDollyPreviewUrl = ( baseUrl: string, rawValue: string ) => {
+ const trimmedValue = rawValue.trim();
+
+ if ( isHttpUrl( trimmedValue ) ) {
+ return new URL( trimmedValue ).toString();
+ }
+
+ if ( trimmedValue.includes( '.' ) && ! trimmedValue.startsWith( '/' ) ) {
+ try {
+ return new URL( `https://${ trimmedValue }` ).toString();
+ } catch {
+ return 'about:blank';
+ }
+ }
+
+ return resolveWorkspacePreviewUrl( baseUrl, trimmedValue || '/' );
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/session.ts b/apps/studio/src/modules/workspaces/lib/dolly/session.ts
new file mode 100644
index 0000000000..77a86d55aa
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/session.ts
@@ -0,0 +1,519 @@
+import { useMemo, useSyncExternalStore } from 'react';
+import { clearWorkspaceDollyWorkspaceActivityForTests } from 'src/modules/workspaces/lib/dolly/turns';
+import {
+ WORKSPACE_DOLLY_AGENT_ID,
+ type WorkspaceDollyConversationState,
+ type WorkspaceDollyWorkspaceDescriptor,
+} from 'src/modules/workspaces/lib/dolly/types';
+import {
+ flexibleNumber,
+ isRecord,
+ normalizeDollySessionId,
+} from 'src/modules/workspaces/lib/dolly/utils';
+import type { RemoteTarget, StudioWorkspace } from 'src/modules/workspaces/types';
+import type { Message as MessageType } from 'src/stores/chat-slice';
+
+export const WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY = 'studio_workspace_dolly_conversations_v2';
+
+type PersistedWorkspaceDollyCache = {
+ version: 2;
+ conversations: Record< string, WorkspaceDollyConversationState >;
+ selectedConversationIdsByWorkspaceId: Record< string, string >;
+ hiddenRemoteConversationKeysByWorkspaceId: Record< string, string[] >;
+};
+
+const conversationCache = new Map< string, WorkspaceDollyConversationState >();
+const selectedConversationIdsByWorkspaceId = new Map< string, string >();
+const hiddenRemoteConversationKeysByWorkspaceId = new Map< string, Set< string > >();
+const conversationCacheSubscribers = new Set< () => void >();
+let hasLoadedConversationCache = false;
+let conversationCacheVersion = 0;
+
+const emitConversationCacheChange = () => {
+ conversationCacheVersion += 1;
+ for ( const subscriber of conversationCacheSubscribers ) {
+ subscriber();
+ }
+};
+
+const subscribeWorkspaceDollyConversationCache = ( subscriber: () => void ) => {
+ conversationCacheSubscribers.add( subscriber );
+ return () => {
+ conversationCacheSubscribers.delete( subscriber );
+ };
+};
+
+const getWorkspaceDollyConversationCacheSnapshot = () => {
+ loadWorkspaceDollyConversationCache();
+ return conversationCacheVersion;
+};
+
+export const createWorkspaceDollyWorkspaceCacheKey = ( {
+ workspaceId,
+}: WorkspaceDollyWorkspaceDescriptor ) => workspaceId;
+
+export const createWorkspaceDollyWorkspaceDescriptor = (
+ workspace: StudioWorkspace
+): WorkspaceDollyWorkspaceDescriptor => ( {
+ workspaceId: workspace.id,
+ workspace,
+ remoteTargets: [ workspace.targets.production, workspace.targets.staging ].filter(
+ ( target ): target is RemoteTarget => Boolean( target )
+ ),
+} );
+
+const createWorkspaceDollyConversationWorkspaceCacheKey = (
+ conversationState: WorkspaceDollyConversationState
+) => conversationState.key.workspaceId;
+
+export const createWorkspaceDollyConversationId = () => `local:${ crypto.randomUUID() }`;
+
+const createRemoteConversationKeys = (
+ conversationState: Pick< WorkspaceDollyConversationState, 'remoteChatId' | 'sessionId' >
+) => {
+ const keys: string[] = [];
+ if ( conversationState.remoteChatId !== undefined ) {
+ keys.push( `chat:${ conversationState.remoteChatId }` );
+ }
+
+ const normalizedSessionId = normalizeDollySessionId( conversationState.sessionId );
+ if ( normalizedSessionId ) {
+ keys.push( `session:${ normalizedSessionId }` );
+ }
+
+ return keys;
+};
+
+const isRemoteConversationHidden = ( conversationState: WorkspaceDollyConversationState ) => {
+ const hiddenKeys = hiddenRemoteConversationKeysByWorkspaceId.get(
+ createWorkspaceDollyConversationWorkspaceCacheKey( conversationState )
+ );
+ if ( ! hiddenKeys ) {
+ return false;
+ }
+
+ return createRemoteConversationKeys( conversationState ).some( ( key ) => hiddenKeys.has( key ) );
+};
+
+const addHiddenRemoteConversation = ( conversationState: WorkspaceDollyConversationState ) => {
+ const keys = createRemoteConversationKeys( conversationState );
+ if ( keys.length === 0 ) {
+ return;
+ }
+
+ const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( conversationState );
+ const hiddenKeys =
+ hiddenRemoteConversationKeysByWorkspaceId.get( workspaceId ) ?? new Set< string >();
+ keys.forEach( ( key ) => hiddenKeys.add( key ) );
+ hiddenRemoteConversationKeysByWorkspaceId.set( workspaceId, hiddenKeys );
+};
+
+export const cloneWorkspaceDollyConversationState = (
+ conversationState: WorkspaceDollyConversationState
+): WorkspaceDollyConversationState => ( {
+ ...conversationState,
+ key: { ...conversationState.key },
+ messages: conversationState.messages.map( ( message ) => {
+ const { failedMessage: _failedMessage, ...messageWithoutRuntimeState } = message;
+ return messageWithoutRuntimeState;
+ } ),
+} );
+
+const normalizePersistedWorkspaceDollyConversationState = (
+ value: unknown
+): WorkspaceDollyConversationState | undefined => {
+ if ( ! isRecord( value ) || ! isRecord( value.key ) ) {
+ return undefined;
+ }
+
+ const workspaceId = typeof value.key.workspaceId === 'string' ? value.key.workspaceId : undefined;
+ const agentId = typeof value.key.agentId === 'string' ? value.key.agentId : undefined;
+
+ if ( ! workspaceId || agentId !== WORKSPACE_DOLLY_AGENT_ID ) {
+ return undefined;
+ }
+
+ const id =
+ typeof value.id === 'string' && value.id.trim()
+ ? value.id
+ : createWorkspaceDollyConversationId();
+
+ return {
+ id,
+ key: {
+ workspaceId,
+ agentId: WORKSPACE_DOLLY_AGENT_ID,
+ },
+ remoteChatId: flexibleNumber( value.remoteChatId ),
+ serverHydrationDisabled:
+ typeof value.serverHydrationDisabled === 'boolean' ? value.serverHydrationDisabled : false,
+ input: typeof value.input === 'string' ? value.input : '',
+ messages: Array.isArray( value.messages )
+ ? ( value.messages as MessageType[] ).map( ( message ) => {
+ const { failedMessage: _failedMessage, ...messageWithoutRuntimeState } = message;
+ return messageWithoutRuntimeState;
+ } )
+ : [],
+ sessionId: typeof value.sessionId === 'string' ? value.sessionId : undefined,
+ lastUpdated: flexibleNumber( value.lastUpdated ) ?? Date.now(),
+ };
+};
+
+const addConversationStateToCache = ( conversationState: WorkspaceDollyConversationState ) => {
+ conversationCache.set( conversationState.id, conversationState );
+
+ const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( conversationState );
+ if ( ! selectedConversationIdsByWorkspaceId.has( workspaceId ) ) {
+ selectedConversationIdsByWorkspaceId.set( workspaceId, conversationState.id );
+ }
+};
+
+const loadPersistedWorkspaceDollyCache = ( parsed: unknown ) => {
+ if ( ! isRecord( parsed ) || parsed.version !== 2 || ! isRecord( parsed.conversations ) ) {
+ return false;
+ }
+
+ for ( const value of Object.values( parsed.conversations ) ) {
+ const conversationState = normalizePersistedWorkspaceDollyConversationState( value );
+ if ( conversationState ) {
+ addConversationStateToCache( conversationState );
+ }
+ }
+
+ if ( isRecord( parsed.selectedConversationIdsByWorkspaceId ) ) {
+ for ( const [ workspaceId, conversationId ] of Object.entries(
+ parsed.selectedConversationIdsByWorkspaceId
+ ) ) {
+ if ( typeof conversationId === 'string' && conversationCache.has( conversationId ) ) {
+ selectedConversationIdsByWorkspaceId.set( workspaceId, conversationId );
+ }
+ }
+ }
+
+ if ( isRecord( parsed.hiddenRemoteConversationKeysByWorkspaceId ) ) {
+ for ( const [ workspaceId, hiddenKeys ] of Object.entries(
+ parsed.hiddenRemoteConversationKeysByWorkspaceId
+ ) ) {
+ if ( Array.isArray( hiddenKeys ) ) {
+ hiddenRemoteConversationKeysByWorkspaceId.set(
+ workspaceId,
+ new Set(
+ hiddenKeys.filter( ( hiddenKey ): hiddenKey is string => typeof hiddenKey === 'string' )
+ )
+ );
+ }
+ }
+ }
+
+ return true;
+};
+
+export const loadWorkspaceDollyConversationCache = () => {
+ if ( hasLoadedConversationCache ) {
+ return;
+ }
+
+ hasLoadedConversationCache = true;
+ const rawCache = localStorage.getItem( WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY );
+ if ( ! rawCache ) {
+ return;
+ }
+
+ try {
+ loadPersistedWorkspaceDollyCache( JSON.parse( rawCache ) );
+ } catch ( error ) {
+ console.error( error );
+ }
+};
+
+export const persistWorkspaceDollyConversationCache = () => {
+ const cache: PersistedWorkspaceDollyCache = {
+ version: 2,
+ conversations: Object.fromEntries(
+ Array.from( conversationCache.entries() ).map( ( [ key, value ] ) => [
+ key,
+ cloneWorkspaceDollyConversationState( value ),
+ ] )
+ ),
+ selectedConversationIdsByWorkspaceId: Object.fromEntries(
+ selectedConversationIdsByWorkspaceId.entries()
+ ),
+ hiddenRemoteConversationKeysByWorkspaceId: Object.fromEntries(
+ Array.from( hiddenRemoteConversationKeysByWorkspaceId.entries() ).map(
+ ( [ workspaceId, hiddenKeys ] ) => [ workspaceId, Array.from( hiddenKeys ) ]
+ )
+ ),
+ };
+ localStorage.setItem( WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY, JSON.stringify( cache ) );
+ emitConversationCacheChange();
+};
+
+export const createWorkspaceDollyConversationState = ( {
+ workspaceId,
+}: WorkspaceDollyWorkspaceDescriptor ): WorkspaceDollyConversationState => ( {
+ id: createWorkspaceDollyConversationId(),
+ key: {
+ workspaceId,
+ agentId: WORKSPACE_DOLLY_AGENT_ID,
+ },
+ remoteChatId: undefined,
+ serverHydrationDisabled: true,
+ input: '',
+ messages: [],
+ sessionId: undefined,
+ lastUpdated: Date.now(),
+} );
+
+export const setSelectedWorkspaceDollyConversationId = (
+ workspace: WorkspaceDollyWorkspaceDescriptor,
+ conversationId: string
+) => {
+ selectedConversationIdsByWorkspaceId.set(
+ createWorkspaceDollyWorkspaceCacheKey( workspace ),
+ conversationId
+ );
+ persistWorkspaceDollyConversationCache();
+};
+
+export const createNewWorkspaceDollyConversation = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ loadWorkspaceDollyConversationCache();
+ const conversationState = createWorkspaceDollyConversationState( workspace );
+ conversationCache.set( conversationState.id, conversationState );
+ setSelectedWorkspaceDollyConversationId( workspace, conversationState.id );
+ return cloneWorkspaceDollyConversationState( conversationState );
+};
+
+const conversationMatchesWorkspace = (
+ conversationState: WorkspaceDollyConversationState,
+ { workspaceId }: WorkspaceDollyWorkspaceDescriptor
+) => conversationState.key.workspaceId === workspaceId;
+
+export const getWorkspaceDollyConversationsForWorkspace = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ loadWorkspaceDollyConversationCache();
+ return Array.from( conversationCache.values() )
+ .filter( ( conversationState ) => conversationMatchesWorkspace( conversationState, workspace ) )
+ .filter( ( conversationState ) => ! isRemoteConversationHidden( conversationState ) )
+ .sort( ( first, second ) => second.lastUpdated - first.lastUpdated )
+ .map( cloneWorkspaceDollyConversationState );
+};
+
+export const getSelectedWorkspaceDollyConversationId = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ loadWorkspaceDollyConversationCache();
+ return selectedConversationIdsByWorkspaceId.get(
+ createWorkspaceDollyWorkspaceCacheKey( workspace )
+ );
+};
+
+export const getWorkspaceDollyConversationState = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ loadWorkspaceDollyConversationCache();
+ const workspaceId = createWorkspaceDollyWorkspaceCacheKey( workspace );
+ const selectedConversationId = selectedConversationIdsByWorkspaceId.get( workspaceId );
+ const cachedConversationState = selectedConversationId
+ ? conversationCache.get( selectedConversationId )
+ : undefined;
+
+ if (
+ ! cachedConversationState ||
+ ! conversationMatchesWorkspace( cachedConversationState, workspace )
+ ) {
+ return createNewWorkspaceDollyConversation( workspace );
+ }
+
+ return cloneWorkspaceDollyConversationState( cachedConversationState );
+};
+
+export const getCachedWorkspaceDollyConversationState = ( conversationId: string ) => {
+ loadWorkspaceDollyConversationCache();
+ const conversationState = conversationCache.get( conversationId );
+ return conversationState ? cloneWorkspaceDollyConversationState( conversationState ) : undefined;
+};
+
+export const writeWorkspaceDollyConversationState = (
+ conversationState: WorkspaceDollyConversationState
+) => {
+ loadWorkspaceDollyConversationCache();
+ conversationCache.set( conversationState.id, conversationState );
+ selectedConversationIdsByWorkspaceId.set(
+ createWorkspaceDollyConversationWorkspaceCacheKey( conversationState ),
+ conversationState.id
+ );
+ persistWorkspaceDollyConversationCache();
+ return cloneWorkspaceDollyConversationState( conversationState );
+};
+
+export const deleteWorkspaceDollyConversation = (
+ conversationId: string,
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ loadWorkspaceDollyConversationCache();
+ const conversationState = conversationCache.get( conversationId );
+ if ( conversationState && conversationMatchesWorkspace( conversationState, workspace ) ) {
+ addHiddenRemoteConversation( conversationState );
+ conversationCache.delete( conversationId );
+ }
+
+ const conversations = getWorkspaceDollyConversationsForWorkspace( workspace );
+ const nextConversation = conversations[ 0 ];
+ if ( nextConversation ) {
+ setSelectedWorkspaceDollyConversationId( workspace, nextConversation.id );
+ return nextConversation;
+ }
+
+ return createNewWorkspaceDollyConversation( workspace );
+};
+
+export const shouldApplyWorkspaceDollyHydration = (
+ currentConversationState: WorkspaceDollyConversationState,
+ hydratedConversationState: WorkspaceDollyConversationState
+) => {
+ const remoteChatMatches =
+ currentConversationState.remoteChatId !== undefined &&
+ currentConversationState.remoteChatId === hydratedConversationState.remoteChatId;
+ const currentSessionId = normalizeDollySessionId( currentConversationState.sessionId );
+ const hydratedSessionId = normalizeDollySessionId( hydratedConversationState.sessionId );
+ const sessionMatches =
+ currentSessionId !== undefined &&
+ hydratedSessionId !== undefined &&
+ currentSessionId === hydratedSessionId;
+
+ if (
+ currentConversationState.serverHydrationDisabled &&
+ ! remoteChatMatches &&
+ ! sessionMatches
+ ) {
+ return false;
+ }
+
+ if ( currentConversationState.messages.length === 0 ) {
+ return true;
+ }
+
+ if ( remoteChatMatches || sessionMatches ) {
+ return true;
+ }
+
+ if ( currentConversationState.input.trim() ) {
+ return false;
+ }
+
+ if ( currentConversationState.remoteChatId === undefined ) {
+ return true;
+ }
+
+ return hydratedConversationState.lastUpdated > currentConversationState.lastUpdated;
+};
+
+export const mergeWorkspaceDollyConversationState = (
+ hydratedConversationState: WorkspaceDollyConversationState,
+ { selectIfEmpty = false }: { selectIfEmpty?: boolean } = {}
+) => {
+ loadWorkspaceDollyConversationCache();
+ if ( isRemoteConversationHidden( hydratedConversationState ) ) {
+ return cloneWorkspaceDollyConversationState( hydratedConversationState );
+ }
+
+ const matchingConversation = Array.from( conversationCache.values() ).find( ( candidate ) => {
+ if ( candidate.key.workspaceId !== hydratedConversationState.key.workspaceId ) {
+ return false;
+ }
+
+ if (
+ candidate.remoteChatId !== undefined &&
+ candidate.remoteChatId === hydratedConversationState.remoteChatId
+ ) {
+ return true;
+ }
+
+ const candidateSessionId = normalizeDollySessionId( candidate.sessionId );
+ const hydratedSessionId = normalizeDollySessionId( hydratedConversationState.sessionId );
+ return (
+ candidateSessionId !== undefined &&
+ hydratedSessionId !== undefined &&
+ candidateSessionId === hydratedSessionId
+ );
+ } );
+ const currentConversationState = matchingConversation
+ ? cloneWorkspaceDollyConversationState( matchingConversation )
+ : undefined;
+ const nextConversationState = currentConversationState
+ ? {
+ ...hydratedConversationState,
+ id: currentConversationState.id,
+ input: currentConversationState.input,
+ }
+ : hydratedConversationState;
+
+ if (
+ currentConversationState &&
+ ! shouldApplyWorkspaceDollyHydration( currentConversationState, hydratedConversationState )
+ ) {
+ return currentConversationState;
+ }
+
+ conversationCache.set( nextConversationState.id, nextConversationState );
+
+ const workspaceId = createWorkspaceDollyConversationWorkspaceCacheKey( nextConversationState );
+ const selectedConversationId = selectedConversationIdsByWorkspaceId.get( workspaceId );
+ const selectedConversation = selectedConversationId
+ ? conversationCache.get( selectedConversationId )
+ : undefined;
+ if (
+ ! selectedConversation ||
+ ( selectIfEmpty &&
+ selectedConversation.messages.length === 0 &&
+ ! selectedConversation.input.trim() )
+ ) {
+ selectedConversationIdsByWorkspaceId.set( workspaceId, nextConversationState.id );
+ }
+
+ persistWorkspaceDollyConversationCache();
+ return cloneWorkspaceDollyConversationState( nextConversationState );
+};
+
+export const clearWorkspaceDollyAssistantStateCacheForTests = () => {
+ conversationCache.clear();
+ selectedConversationIdsByWorkspaceId.clear();
+ hiddenRemoteConversationKeysByWorkspaceId.clear();
+ clearWorkspaceDollyWorkspaceActivityForTests();
+ hasLoadedConversationCache = false;
+ localStorage.removeItem( WORKSPACE_DOLLY_CONVERSATIONS_STORAGE_KEY );
+ emitConversationCacheChange();
+};
+
+export const useWorkspaceDollyConversationsForWorkspace = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ const version = useSyncExternalStore(
+ subscribeWorkspaceDollyConversationCache,
+ getWorkspaceDollyConversationCacheSnapshot,
+ () => 0
+ );
+
+ return useMemo( () => {
+ void version;
+ return getWorkspaceDollyConversationsForWorkspace( workspace );
+ }, [ version, workspace ] );
+};
+
+export const useSelectedWorkspaceDollyConversationId = (
+ workspace: WorkspaceDollyWorkspaceDescriptor
+) => {
+ const version = useSyncExternalStore(
+ subscribeWorkspaceDollyConversationCache,
+ getWorkspaceDollyConversationCacheSnapshot,
+ () => 0
+ );
+
+ return useMemo( () => {
+ void version;
+ return getSelectedWorkspaceDollyConversationId( workspace );
+ }, [ version, workspace ] );
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/transport.ts b/apps/studio/src/modules/workspaces/lib/dolly/transport.ts
new file mode 100644
index 0000000000..fc3cf1bb22
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/transport.ts
@@ -0,0 +1,213 @@
+import {
+ extractTextFromMessage,
+ getAgentManager,
+ type TaskUpdate,
+ type ToolProvider,
+} from '@automattic/agenttic-client';
+import { __ } from '@wordpress/i18n';
+import { createWorkspaceDollyImageUrl } from 'src/modules/workspaces/lib/dolly/media';
+import {
+ createWorkspaceDollyAgentManagerKey,
+ createWorkspaceDollyAgentUrl,
+ createWorkspaceDollyAuthProvider,
+ createWorkspaceDollyContextProvider,
+} from 'src/modules/workspaces/lib/dolly/preview';
+import {
+ WORKSPACE_DOLLY_AGENT_ID,
+ WORKSPACE_DOLLY_MEDIA_RETRY_DELAYS_MS,
+ WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS,
+ type WorkspaceDollyAgentResponse,
+ type WorkspaceDollyPreviewContext,
+ type WorkspaceDollySiteAssociationContext,
+ type WorkspaceDollyUploadedImage,
+} from 'src/modules/workspaces/lib/dolly/types';
+import { extractBackendSelectedSiteId } from 'src/modules/workspaces/lib/dolly/utils';
+import type { PreviewAbilityTarget } from 'src/modules/workspaces/lib/dolly/preview';
+import type { RemoteTarget, StudioWorkspace } from 'src/modules/workspaces/types';
+
+export const getWorkspaceDollyErrorMessage = ( error: unknown ) =>
+ error instanceof Error ? error.message : String( error );
+
+export const createWorkspaceDollyRequestAbortError = () => {
+ const message = 'Dolly request was stopped.';
+ if ( typeof DOMException !== 'undefined' ) {
+ return new DOMException( message, 'AbortError' );
+ }
+
+ const error = new Error( message );
+ error.name = 'AbortError';
+ return error;
+};
+
+export const isWorkspaceDollyRequestAbortError = ( error: unknown ) =>
+ ( typeof DOMException !== 'undefined' &&
+ error instanceof DOMException &&
+ error.name === 'AbortError' ) ||
+ ( error instanceof Error && error.name === 'AbortError' );
+
+const parseWorkspaceDollyTaskUpdate = (
+ response: TaskUpdate,
+ fallbackSessionId: string
+): WorkspaceDollyAgentResponse => {
+ if ( response.status.error ) {
+ throw new Error( response.status.error.message || 'Dolly returned an error.' );
+ }
+
+ const messageText = response.status.message
+ ? extractTextFromMessage( response.status.message )
+ : response.text;
+ const text = messageText.trim();
+
+ return {
+ text: text || __( 'Dolly did not return a text response.' ),
+ sessionId: response.sessionId ?? fallbackSessionId,
+ selectedSiteId: extractBackendSelectedSiteId( response ),
+ };
+};
+
+const isWorkspaceDollyToolResultProtocolError = ( error: unknown ) => {
+ const message = error instanceof Error ? error.message : String( error );
+ return (
+ message.includes( 'Tool calls without results' ) ||
+ message.includes( 'Protocol request error: Invalid message' )
+ );
+};
+
+const shouldRetryWorkspaceDollyMediaRequest = (
+ error: unknown,
+ uploadedImages: WorkspaceDollyUploadedImage[]
+) =>
+ uploadedImages.length > 0 &&
+ getWorkspaceDollyErrorMessage( error ).toLowerCase().includes( 'processing the request' );
+
+const delay = ( milliseconds: number, abortSignal?: AbortSignal ) =>
+ new Promise< void >( ( resolve, reject ) => {
+ if ( abortSignal?.aborted ) {
+ reject( createWorkspaceDollyRequestAbortError() );
+ return;
+ }
+
+ const timeoutId = window.setTimeout( () => {
+ abortSignal?.removeEventListener( 'abort', abort );
+ resolve();
+ }, milliseconds );
+ function abort() {
+ window.clearTimeout( timeoutId );
+ reject( createWorkspaceDollyRequestAbortError() );
+ }
+ abortSignal?.addEventListener( 'abort', abort, { once: true } );
+ } );
+
+export const sendWorkspaceDollyMessage = async ( {
+ message,
+ uploadedImages,
+ previewContext,
+ siteAssociation,
+ workspace,
+ transportTarget,
+ sessionId,
+ workspaceId,
+ targets,
+ toolProvider,
+ abortSignal,
+}: {
+ message: string;
+ uploadedImages?: WorkspaceDollyUploadedImage[];
+ previewContext: WorkspaceDollyPreviewContext;
+ siteAssociation: WorkspaceDollySiteAssociationContext;
+ workspace: StudioWorkspace;
+ transportTarget: RemoteTarget;
+ sessionId?: string;
+ workspaceId: string;
+ targets: PreviewAbilityTarget[];
+ toolProvider?: ToolProvider;
+ abortSignal?: AbortSignal;
+} ): Promise< WorkspaceDollyAgentResponse > => {
+ const taskId = crypto.randomUUID();
+ const initialSessionId = sessionId ?? taskId;
+ const agentManager = getAgentManager();
+ const agentManagerKey = createWorkspaceDollyAgentManagerKey(
+ workspaceId,
+ transportTarget.site.id
+ );
+ const sendInitialMessage = async ( nextTaskId: string, nextSessionId: string ) => {
+ agentManager.removeAgent( agentManagerKey );
+ await agentManager.createAgent( agentManagerKey, {
+ agentId: WORKSPACE_DOLLY_AGENT_ID,
+ agentUrl: createWorkspaceDollyAgentUrl( transportTarget.site.id ),
+ authProvider: createWorkspaceDollyAuthProvider(),
+ contextProvider: createWorkspaceDollyContextProvider(
+ workspaceId,
+ workspace,
+ transportTarget,
+ previewContext,
+ siteAssociation,
+ targets
+ ),
+ toolProvider,
+ timeout: WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS,
+ } );
+
+ try {
+ let finalUpdate: TaskUpdate | undefined;
+ for await ( const update of agentManager.sendMessageStream( agentManagerKey, message, {
+ imageUrls: uploadedImages?.map( createWorkspaceDollyImageUrl ),
+ sessionId: nextSessionId,
+ taskId: nextTaskId,
+ abortSignal,
+ enableStreaming: false,
+ } ) ) {
+ finalUpdate = update;
+ }
+
+ if ( ! finalUpdate ) {
+ throw new Error( __( 'Dolly did not return a response.' ) );
+ }
+
+ return parseWorkspaceDollyTaskUpdate( finalUpdate, nextSessionId );
+ } finally {
+ try {
+ await agentManager.resetConversation( agentManagerKey );
+ } catch {
+ // The workspace Dolly cache is the source of truth between requests.
+ }
+ agentManager.removeAgent( agentManagerKey );
+ }
+ };
+
+ let response: WorkspaceDollyAgentResponse | undefined;
+ try {
+ for ( let attempt = 0; ; attempt++ ) {
+ try {
+ response = await sendInitialMessage( taskId, initialSessionId );
+ break;
+ } catch ( error ) {
+ if (
+ attempt >= WORKSPACE_DOLLY_MEDIA_RETRY_DELAYS_MS.length ||
+ ! shouldRetryWorkspaceDollyMediaRequest( error, uploadedImages ?? [] )
+ ) {
+ throw error;
+ }
+ await delay( WORKSPACE_DOLLY_MEDIA_RETRY_DELAYS_MS[ attempt ], abortSignal );
+ }
+ }
+ } catch ( error ) {
+ if (
+ isWorkspaceDollyRequestAbortError( error ) ||
+ abortSignal?.aborted ||
+ ! sessionId ||
+ ! isWorkspaceDollyToolResultProtocolError( error )
+ ) {
+ throw error;
+ }
+
+ const freshTaskId = crypto.randomUUID();
+ response = await sendInitialMessage( freshTaskId, freshTaskId );
+ }
+
+ if ( ! response ) {
+ throw new Error( __( 'Dolly did not return a response.' ) );
+ }
+
+ return response;
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/turns.ts b/apps/studio/src/modules/workspaces/lib/dolly/turns.ts
new file mode 100644
index 0000000000..04d6bdfc22
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/turns.ts
@@ -0,0 +1,110 @@
+import { useMemo, useSyncExternalStore } from 'react';
+import type { WorkspaceDollyWorkspaceActivity } from 'src/modules/workspaces/lib/dolly/types';
+
+type WorkspaceDollyTurn = {
+ conversationId: string;
+ workspaceId: string;
+ abortController: AbortController;
+};
+
+const activeTurns = new Map< string, WorkspaceDollyTurn >();
+const workspaceActivities = new Map< string, WorkspaceDollyWorkspaceActivity >();
+const subscribers = new Set< () => void >();
+let activityVersion = 0;
+
+const emitChange = () => {
+ activityVersion += 1;
+ for ( const subscriber of subscribers ) {
+ subscriber();
+ }
+};
+
+const subscribe = ( subscriber: () => void ) => {
+ subscribers.add( subscriber );
+ return () => {
+ subscribers.delete( subscriber );
+ };
+};
+
+const setWorkspaceActivity = (
+ workspaceId: string,
+ activity: Partial< WorkspaceDollyWorkspaceActivity >
+) => {
+ const nextActivity = {
+ ...workspaceActivities.get( workspaceId ),
+ ...activity,
+ };
+ const hasActiveActivity = Object.values( nextActivity ).some( Boolean );
+
+ if ( hasActiveActivity ) {
+ workspaceActivities.set( workspaceId, nextActivity );
+ } else {
+ workspaceActivities.delete( workspaceId );
+ }
+ emitChange();
+};
+
+const refreshThinkingActivity = ( workspaceId: string ) => {
+ const hasActiveTurn = Array.from( activeTurns.values() ).some(
+ ( turn ) => turn.workspaceId === workspaceId
+ );
+ setWorkspaceActivity( workspaceId, { isAssistantThinking: hasActiveTurn } );
+};
+
+export const getWorkspaceDollyTurn = ( conversationId: string ) =>
+ activeTurns.get( conversationId );
+
+export const startWorkspaceDollyTurn = ( turn: WorkspaceDollyTurn ) => {
+ activeTurns.set( turn.conversationId, turn );
+ setWorkspaceActivity( turn.workspaceId, { isAssistantThinking: true } );
+};
+
+export const finishWorkspaceDollyTurn = (
+ conversationId: string,
+ abortController: AbortController
+) => {
+ const activeTurn = activeTurns.get( conversationId );
+ if ( activeTurn?.abortController !== abortController ) {
+ return;
+ }
+
+ activeTurns.delete( conversationId );
+ refreshThinkingActivity( activeTurn.workspaceId );
+};
+
+export const abortWorkspaceDollyTurn = ( conversationId: string ) => {
+ activeTurns.get( conversationId )?.abortController.abort();
+};
+
+export const setWorkspaceDollyWorkspaceUnread = (
+ workspaceId: string,
+ hasUnreadAssistantMessage: boolean
+) => {
+ setWorkspaceActivity( workspaceId, { hasUnreadAssistantMessage } );
+};
+
+export const clearWorkspaceDollyWorkspaceActivityForTests = () => {
+ activeTurns.clear();
+ workspaceActivities.clear();
+ emitChange();
+};
+
+export const useWorkspaceDollyConversationTurn = ( conversationId: string ) =>
+ useSyncExternalStore(
+ subscribe,
+ () => Boolean( activeTurns.get( conversationId ) ),
+ () => false
+ );
+
+export const useWorkspaceDollyWorkspaceActivity = ( workspaceId: string ) => {
+ const version = useSyncExternalStore(
+ subscribe,
+ () => activityVersion,
+ () => 0
+ );
+
+ return useMemo( () => {
+ void version;
+ return { ...workspaceActivities.get( workspaceId ) };
+ }, [ version, workspaceId ] );
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/types.ts b/apps/studio/src/modules/workspaces/lib/dolly/types.ts
new file mode 100644
index 0000000000..858b11e824
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/types.ts
@@ -0,0 +1,145 @@
+import type { SendMessageParams } from '@automattic/agenttic-client';
+import type { UploadedImage } from '@automattic/agenttic-ui';
+import type {
+ RemoteTarget,
+ WorkspaceTargetId,
+ StudioWorkspace,
+} from 'src/modules/workspaces/types';
+import type { Message as MessageType } from 'src/stores/chat-slice';
+
+export const WORKSPACE_DOLLY_AGENT_ID = 'dolly';
+export const WORKSPACE_DOLLY_AGENT_URL_ORIGIN = 'https://public-api.wordpress.com/wpcom/v2';
+export const WORKSPACE_DOLLY_MEDIA_UPLOAD_URL_ORIGIN = 'https://public-api.wordpress.com/rest/v1.1';
+export const WORKSPACE_DOLLY_HISTORY_BOT_ID = 'wpcom-agent-dolly';
+export const WORKSPACE_DOLLY_HISTORY_CLIENT = 'wpworkspace';
+export const WORKSPACE_DOLLY_PREVIEW_TOOL_ID = 'wpworkspace/preview';
+export const WORKSPACE_DOLLY_REFRESH_PREVIEW_TOOL_ID = 'wpworkspace/refresh_preview';
+export const WORKSPACE_DOLLY_FRONTEND_ABILITIES = [
+ WORKSPACE_DOLLY_PREVIEW_TOOL_ID,
+ WORKSPACE_DOLLY_REFRESH_PREVIEW_TOOL_ID,
+];
+export const WORKSPACE_DOLLY_REQUEST_TIMEOUT_MS = 90_000;
+export const WORKSPACE_DOLLY_HISTORY_SUMMARY_ITEMS_PER_PAGE = 20;
+export const WORKSPACE_DOLLY_HISTORY_CHAT_ITEMS_PER_PAGE = 100;
+export const WORKSPACE_DOLLY_HISTORY_MAX_PAGES = 10;
+export const WORKSPACE_DOLLY_IMAGE_FILE_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/webp',
+];
+export const WORKSPACE_DOLLY_IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024;
+export const WORKSPACE_DOLLY_IMAGE_MAX_FILES = 4;
+export const WORKSPACE_DOLLY_MEDIA_RETRY_DELAYS_MS = [ 1500, 4000 ];
+export const WORKSPACE_DOLLY_IMAGE_PRELOAD_TIMEOUT_MS = 750;
+
+export type WorkspaceDollyConversationKey = {
+ workspaceId: string;
+ agentId: typeof WORKSPACE_DOLLY_AGENT_ID;
+};
+
+export type WorkspaceDollyConversationState = {
+ id: string;
+ key: WorkspaceDollyConversationKey;
+ remoteChatId?: number;
+ serverHydrationDisabled?: boolean;
+ input: string;
+ messages: MessageType[];
+ sessionId?: string;
+ lastUpdated: number;
+};
+
+export type WorkspaceDollyWorkspaceActivity = {
+ isAssistantThinking?: boolean;
+ hasUnreadAssistantMessage?: boolean;
+};
+
+export type WorkspaceDollyAgentResponse = {
+ text: string;
+ sessionId?: string;
+ selectedSiteId?: number;
+};
+
+export type WorkspaceDollyPendingImage = UploadedImage & {
+ file: File;
+ dataUrl?: string;
+};
+
+export type WorkspaceDollyVisibleImage = {
+ name: string;
+ url: string;
+};
+
+export type WorkspaceDollyMessageImageAttachment = {
+ text: string;
+ images: WorkspaceDollyVisibleImage[];
+};
+
+export type WorkspaceDollyUploadedImage = {
+ id: number;
+ url: string;
+ name: string;
+ mimeType: string;
+ fileName?: string;
+ title?: string;
+};
+
+export type WorkspaceDollyAgentImageUrl = NonNullable< SendMessageParams[ 'imageUrls' ] >[ number ];
+
+export type WorkspaceDollyHistoryMessage = {
+ content: string;
+ role: 'user' | 'assistant';
+ createdAt: number;
+ messageApiId?: number;
+};
+
+export type WorkspaceDollyHistorySummary = {
+ chatId: number;
+ sessionId?: string;
+ siteId?: number;
+ createdAt?: number;
+ firstMessage?: Record< string, unknown >;
+ lastMessage?: Record< string, unknown >;
+};
+
+export type WorkspaceDollyHistoryChat = {
+ chatId: number;
+ sessionId?: string;
+ siteId?: number;
+ createdAt?: number;
+ messages: WorkspaceDollyHistoryMessage[];
+};
+
+export type WorkspaceDollyPreviewContext = {
+ isOpen: boolean;
+ targetId?: WorkspaceTargetId;
+ siteId?: number | string;
+ openedURL?: string;
+ currentURL?: string;
+ isLoading: boolean;
+};
+
+export type WorkspaceDollySiteAssociationContext = {
+ status: 'workspace';
+ workspaceId: string;
+ transportTargetId: RemoteTarget[ 'id' ];
+ transportWpcomSiteId: number;
+ transportWpcomSiteUrl: string;
+ activeTargetId?: WorkspaceTargetId;
+ activeSiteId?: number | string;
+ activeSiteUrl?: string;
+ activeSiteBaseUrl?: string;
+ targets: Array< {
+ targetId: WorkspaceTargetId;
+ siteId?: number | string;
+ siteUrl: string;
+ isProduction?: boolean;
+ } >;
+ instructions: string;
+};
+
+export type WorkspaceDollyWorkspaceDescriptor = {
+ workspaceId: string;
+ workspace?: StudioWorkspace;
+ remoteTargets: RemoteTarget[];
+};
diff --git a/apps/studio/src/modules/workspaces/lib/dolly/utils.ts b/apps/studio/src/modules/workspaces/lib/dolly/utils.ts
new file mode 100644
index 0000000000..c9da4412dc
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/lib/dolly/utils.ts
@@ -0,0 +1,96 @@
+export const isRecord = ( value: unknown ): value is Record< string, unknown > =>
+ typeof value === 'object' && value !== null;
+
+export const flexibleNumber = ( value: unknown ): number | undefined => {
+ if ( typeof value === 'number' ) {
+ return value;
+ }
+ if ( typeof value === 'string' ) {
+ const parsed = Number( value );
+ return Number.isFinite( parsed ) ? parsed : undefined;
+ }
+ return undefined;
+};
+
+export const getFlexibleNumberValue = (
+ record: Record< string, unknown >,
+ possibleKeys: string[]
+): number | undefined => {
+ for ( const key of possibleKeys ) {
+ const value = flexibleNumber( record[ key ] );
+ if ( value && value > 0 ) {
+ return value;
+ }
+ }
+};
+
+export const getStringFromRecord = (
+ record: Record< string, unknown >,
+ possibleKeys: string[]
+): string | undefined => {
+ for ( const key of possibleKeys ) {
+ const value = record[ key ];
+ if ( typeof value === 'string' && value.trim() ) {
+ return value.trim();
+ }
+ }
+};
+
+export const normalizeDollySessionId = ( value?: string ) => {
+ const trimmedValue = value?.trim();
+ return trimmedValue || undefined;
+};
+
+export const hasHttpProtocol = ( url: URL ) =>
+ url.protocol === 'http:' || url.protocol === 'https:';
+
+export const normalizeSiteBaseUrl = ( value?: string ): string | undefined => {
+ const trimmedValue = value?.trim();
+ if ( ! trimmedValue ) {
+ return undefined;
+ }
+
+ const parseUrl = ( candidate: string ) => {
+ try {
+ const url = new URL( candidate );
+ if ( ! hasHttpProtocol( url ) ) {
+ return undefined;
+ }
+ return url.pathname === '/' && ! url.search && ! url.hash ? url.origin : url.toString();
+ } catch {
+ return undefined;
+ }
+ };
+
+ return (
+ parseUrl( trimmedValue ) ??
+ ( trimmedValue.startsWith( '//' )
+ ? parseUrl( `https:${ trimmedValue }` )
+ : parseUrl( `https://${ trimmedValue }` ) )
+ );
+};
+
+export const extractBackendSelectedSiteIdFromRecord = (
+ record: Record< string, unknown >
+): number | undefined =>
+ getFlexibleNumberValue( record, [
+ 'selectedSiteId',
+ 'selected_site_id',
+ 'siteId',
+ 'site_id',
+ 'blog_id',
+ 'blogID',
+ ] );
+
+export const extractBackendSelectedSiteId = ( response: unknown ): number | undefined => {
+ if ( ! isRecord( response ) ) {
+ return undefined;
+ }
+
+ return (
+ extractBackendSelectedSiteIdFromRecord( response ) ??
+ ( isRecord( response.result )
+ ? extractBackendSelectedSiteIdFromRecord( response.result )
+ : undefined )
+ );
+};
diff --git a/apps/studio/src/modules/workspaces/types.ts b/apps/studio/src/modules/workspaces/types.ts
new file mode 100644
index 0000000000..9fac7c0b42
--- /dev/null
+++ b/apps/studio/src/modules/workspaces/types.ts
@@ -0,0 +1,49 @@
+import type { SyncSite } from '@studio/common/types/sync';
+
+export type WorkspaceTargetId = 'local' | 'production' | 'staging';
+
+export type LocalTarget = {
+ id: 'local';
+ kind: 'local';
+ siteId: string;
+ site: SiteDetails;
+};
+
+export type RemoteTargetId = Extract< WorkspaceTargetId, 'production' | 'staging' >;
+
+export type RemoteTarget = {
+ id: RemoteTargetId;
+ kind: 'remote';
+ siteId: number;
+ site: SyncSite;
+};
+
+export type WorkspaceSyncLink = {
+ id: string;
+ source: WorkspaceTargetId;
+ target: WorkspaceTargetId;
+ status: 'available';
+};
+
+export type WorkspaceActivity = {
+ status: 'idle';
+};
+
+export type StudioWorkspace = {
+ id: string;
+ name: string;
+ sortOrder?: number;
+ targets: {
+ local?: LocalTarget;
+ production?: RemoteTarget;
+ staging?: RemoteTarget;
+ };
+ syncLinks: WorkspaceSyncLink[];
+ activity: WorkspaceActivity;
+};
+
+export type BuildStudioWorkspacesInput = {
+ localSites?: SiteDetails[];
+ wpcomSites?: SyncSite[];
+ connectedSites?: SyncSite[];
+};
diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts
index 34b0b4907d..88fbfa3255 100644
--- a/apps/studio/src/site-server.ts
+++ b/apps/studio/src/site-server.ts
@@ -228,6 +228,13 @@ export class SiteServer {
console.log( `Starting server for '${ this.details.name }'` );
await this.server.start();
+ const url = getAbsoluteUrl( this.details );
+ this.details = {
+ ...this.details,
+ running: true,
+ url,
+ };
+ this.server.url = url;
}
updateSiteDetails( site: SiteDetails ) {
diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts
index a703b28645..f93936b2b3 100644
--- a/apps/studio/src/stores/index.ts
+++ b/apps/studio/src/stores/index.ts
@@ -20,7 +20,7 @@ import {
refreshSnapshots,
snapshotActions,
} from 'src/stores/snapshot-slice';
-import { syncReducer, syncOperationsActions } from 'src/stores/sync';
+import { stagingSyncReducer, syncReducer, syncOperationsActions } from 'src/stores/sync';
import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites';
import {
syncOperationsReducer,
@@ -41,6 +41,7 @@ export type RootState = {
onboarding: ReturnType< typeof onboardingReducer >;
snapshot: ReturnType< typeof snapshotReducer >;
sync: ReturnType< typeof syncReducer >;
+ stagingSync: ReturnType< typeof stagingSyncReducer >;
connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >;
connectedSites: ReturnType< typeof connectedSitesReducer >;
syncOperations: ReturnType< typeof syncOperationsReducer >;
@@ -332,6 +333,7 @@ export const rootReducer = combineReducers( {
onboarding: onboardingReducer,
snapshot: snapshotReducer,
sync: syncReducer,
+ stagingSync: stagingSyncReducer,
syncOperations: syncOperationsReducer,
wordpressVersionsApi: wordpressVersionsApi.reducer,
wpcomApi: wpcomApi.reducer,
diff --git a/apps/studio/src/stores/sync/index.ts b/apps/studio/src/stores/sync/index.ts
index 0480de18d1..c7bb9fb558 100644
--- a/apps/studio/src/stores/sync/index.ts
+++ b/apps/studio/src/stores/sync/index.ts
@@ -12,6 +12,13 @@ export {
syncOperationsSelectors,
syncOperationsThunks,
} from './sync-operations-slice';
+export {
+ STAGING_SYNC_OPTION_TOKENS,
+ stagingSyncActions,
+ stagingSyncReducer,
+ stagingSyncSelectors,
+ stagingSyncThunks,
+} from './staging-sync-slice';
export type {
SyncBackupState,
PullSiteOptions,
@@ -19,3 +26,11 @@ export type {
SyncPushState,
PushStates,
} from './sync-operations-slice';
+export type {
+ StagingSyncDirection,
+ StagingSyncOption,
+ StagingSyncOptions,
+ StagingSyncPathOptions,
+ StagingSyncState,
+ StagingSyncStatus,
+} from './staging-sync-slice';
diff --git a/apps/studio/src/stores/sync/staging-sync-slice.test.ts b/apps/studio/src/stores/sync/staging-sync-slice.test.ts
new file mode 100644
index 0000000000..6957b30561
--- /dev/null
+++ b/apps/studio/src/stores/sync/staging-sync-slice.test.ts
@@ -0,0 +1,335 @@
+import { configureStore } from '@reduxjs/toolkit';
+import {
+ createStagingSite,
+ fetchStagingSiteSyncState,
+ stagingSyncReducer,
+ startStagingSiteSync,
+ type StagingSyncState,
+} from 'src/stores/sync/staging-sync-slice';
+import type { SyncSite } from '@studio/common/types/sync';
+
+const { mockGetWpcomClient } = vi.hoisted( () => ( {
+ mockGetWpcomClient: vi.fn(),
+} ) );
+
+vi.mock( 'src/stores/wpcom-api', () => ( {
+ getWpcomClient: mockGetWpcomClient,
+} ) );
+
+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,
+} );
+
+describe( 'staging sync reducer', () => {
+ beforeEach( () => {
+ mockGetWpcomClient.mockReset();
+ } );
+
+ it( 'tracks production-scoped staging sync state when a sync starts', () => {
+ const productionSite = createSyncSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+ const action = startStagingSiteSync.pending( 'request-id', {
+ productionSite,
+ stagingSite,
+ direction: 'push',
+ options: [ 'themes', 'plugins' ],
+ } );
+
+ const state = stagingSyncReducer( undefined, action );
+
+ expect( state.states[ 101 ] ).toMatchObject( {
+ productionSiteId: 101,
+ stagingSiteId: 202,
+ status: 'started',
+ direction: 'push',
+ options: [ 'themes', 'plugins' ],
+ } );
+ } );
+
+ it( 'does not let an empty sync-state response erase an active local state', () => {
+ const activeState: StagingSyncState = {
+ productionSiteId: 101,
+ stagingSiteId: 202,
+ status: 'started',
+ direction: 'push',
+ };
+
+ const state = stagingSyncReducer(
+ { states: { 101: activeState } },
+ fetchStagingSiteSyncState.fulfilled(
+ {
+ productionSiteId: 101,
+ status: 'idle',
+ },
+ 'request-id',
+ { productionSiteId: 101 }
+ )
+ );
+
+ expect( state.states[ 101 ] ).toEqual( activeState );
+ } );
+
+ it( 'records completed environment sync state from polling', () => {
+ const state = stagingSyncReducer(
+ undefined,
+ fetchStagingSiteSyncState.fulfilled(
+ {
+ productionSiteId: 101,
+ stagingSiteId: 202,
+ status: 'completed',
+ direction: 'pull',
+ completedAt: '2026-05-14T12:00:00+00:00',
+ },
+ 'request-id',
+ { productionSiteId: 101 }
+ )
+ );
+
+ expect( state.states[ 101 ] ).toMatchObject( {
+ productionSiteId: 101,
+ stagingSiteId: 202,
+ status: 'completed',
+ direction: 'pull',
+ completedAt: '2026-05-14T12:00:00+00:00',
+ } );
+ } );
+
+ it( 'posts production to staging sync requests to the staging endpoint', async () => {
+ const post = vi.fn().mockResolvedValue( { success: true } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { post },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+ const productionSite = createSyncSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+
+ await store.dispatch(
+ startStagingSiteSync( {
+ productionSite,
+ stagingSite,
+ direction: 'push',
+ options: [ 'themes', 'plugins' ],
+ } )
+ );
+
+ expect( post ).toHaveBeenCalledWith( {
+ apiNamespace: 'wpcom/v2',
+ path: '/sites/101/staging-site/push-to-staging/202',
+ body: {
+ options: [ 'themes', 'plugins' ],
+ },
+ } );
+ } );
+
+ it( 'posts full content sync requests to the staging endpoint', async () => {
+ const post = vi.fn().mockResolvedValue( { success: true } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { post },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+ const productionSite = createSyncSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+
+ await store.dispatch(
+ startStagingSiteSync( {
+ productionSite,
+ stagingSite,
+ direction: 'pull',
+ options: [ 'contents', 'roots', 'sqls' ],
+ } )
+ );
+
+ expect( post ).toHaveBeenCalledWith( {
+ apiNamespace: 'wpcom/v2',
+ path: '/sites/101/staging-site/pull-from-staging/202',
+ body: {
+ options: [ 'contents', 'roots', 'sqls' ],
+ },
+ } );
+ } );
+
+ it( 'posts granular file path sync requests to the staging endpoint', async () => {
+ const post = vi.fn().mockResolvedValue( { success: true } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { post },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+ const productionSite = createSyncSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+
+ await store.dispatch(
+ startStagingSiteSync( {
+ productionSite,
+ stagingSite,
+ direction: 'pull',
+ options: {
+ types: 'paths',
+ include_paths: [ 'cjI6,ZjI6Lw==' ],
+ exclude_paths: [],
+ },
+ } )
+ );
+
+ expect( post ).toHaveBeenCalledWith( {
+ apiNamespace: 'wpcom/v2',
+ path: '/sites/101/staging-site/pull-from-staging/202',
+ body: {
+ options: {
+ types: 'paths',
+ include_paths: [ 'cjI6,ZjI6Lw==' ],
+ exclude_paths: [],
+ },
+ },
+ } );
+ } );
+
+ it( 'posts staging-site creation requests to the production staging endpoint', async () => {
+ const post = vi.fn().mockResolvedValue( {
+ id: 202,
+ name: 'Production Site Staging',
+ url: 'https://staging.example',
+ user_has_permission: true,
+ } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { post },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+ const productionSite = createSyncSite();
+
+ const result = await store.dispatch( createStagingSite( { productionSite } ) );
+
+ expect( createStagingSite.fulfilled.match( result ) ).toBe( true );
+ expect( post ).toHaveBeenCalledWith( {
+ apiNamespace: 'wpcom/v2',
+ path: '/sites/101/staging-site',
+ } );
+ expect( result.payload ).toMatchObject( {
+ id: 202,
+ url: 'https://staging.example',
+ } );
+ } );
+
+ it( 'fetches production-scoped staging sync state', async () => {
+ const get = vi.fn().mockResolvedValue( {
+ status: 'completed',
+ staging_blog_id: 202,
+ production_blog_id: 101,
+ direction: 'pull',
+ options: { types: 'contents,roots,themes,plugins' },
+ } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { get },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+
+ await store.dispatch( fetchStagingSiteSyncState( { productionSiteId: 101 } ) );
+
+ expect( get ).toHaveBeenCalledWith( {
+ apiNamespace: 'wpcom/v2',
+ path: '/sites/101/staging-site/sync-state',
+ } );
+ expect( store.getState().states[ 101 ] ).toMatchObject( {
+ status: 'completed',
+ stagingSiteId: 202,
+ direction: 'pull',
+ options: [ 'contents', 'roots', 'themes', 'plugins' ],
+ } );
+ } );
+
+ it( 'normalizes numeric timestamps from staging sync state responses', async () => {
+ const get = vi.fn().mockResolvedValue( {
+ status: 'in-progress',
+ staging_blog_id: 202,
+ production_blog_id: 101,
+ direction: 'pull',
+ started_at: 1778766552,
+ updated_at: 1778766560,
+ } );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { get },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+
+ await store.dispatch( fetchStagingSiteSyncState( { productionSiteId: 101 } ) );
+
+ expect( store.getState().states[ 101 ] ).toMatchObject( {
+ status: 'in-progress',
+ startedAt: '2026-05-14T13:49:12.000Z',
+ updatedAt: '2026-05-14T13:49:20.000Z',
+ } );
+ } );
+
+ it( 'preserves WooCommerce database sync errors for confirm-and-retry handling', async () => {
+ const error = {
+ code: 'rest_sqls_option_not_supported',
+ message: 'WooCommerce database sync requires confirmation.',
+ status: 422,
+ };
+ const post = vi.fn().mockRejectedValue( error );
+ mockGetWpcomClient.mockReturnValue( {
+ req: { post },
+ } );
+ const store = configureStore( {
+ reducer: stagingSyncReducer,
+ } );
+ const productionSite = createSyncSite();
+ const stagingSite = createSyncSite( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ } );
+
+ const result = await store.dispatch(
+ startStagingSiteSync( {
+ productionSite,
+ stagingSite,
+ direction: 'pull',
+ options: [ 'sqls' ],
+ } )
+ );
+
+ expect( startStagingSiteSync.rejected.match( result ) ).toBe( true );
+ expect( result.payload ).toMatchObject( {
+ code: 'rest_sqls_option_not_supported',
+ status: 422,
+ } );
+ expect( result.payload ).not.toHaveProperty( 'raw' );
+ } );
+} );
diff --git a/apps/studio/src/stores/sync/staging-sync-slice.ts b/apps/studio/src/stores/sync/staging-sync-slice.ts
new file mode 100644
index 0000000000..f1ad83bc71
--- /dev/null
+++ b/apps/studio/src/stores/sync/staging-sync-slice.ts
@@ -0,0 +1,452 @@
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import * as Sentry from '@sentry/electron/renderer';
+import { __ } from '@wordpress/i18n';
+import { z } from 'zod';
+import { getWpcomClient } from 'src/stores/wpcom-api';
+import type { SyncSite } from '@studio/common/types/sync';
+import type { RootState } from 'src/stores';
+
+export const STAGING_SYNC_OPTION_TOKENS = [
+ 'sqls',
+ 'contents',
+ 'themes',
+ 'plugins',
+ 'uploads',
+ 'roots',
+] as const;
+export type StagingSyncOption = ( typeof STAGING_SYNC_OPTION_TOKENS )[ number ];
+export type StagingSyncPathOptions = {
+ types: 'paths';
+ include_paths: string[];
+ exclude_paths?: string[];
+};
+export type StagingSyncOptions = StagingSyncOption[] | StagingSyncPathOptions;
+export type StagingSyncDirection = 'push' | 'pull';
+export type StagingSyncStatus =
+ | 'idle'
+ | 'started'
+ | 'in-progress'
+ | 'completed'
+ | 'failed'
+ | 'no-staging'
+ | 'timed-out';
+
+type StagingSyncApiError = {
+ code?: string;
+ message: string;
+ status?: number;
+};
+
+export type StagingSyncState = {
+ productionSiteId: number;
+ stagingSiteId?: number;
+ status: StagingSyncStatus;
+ direction?: StagingSyncDirection;
+ restoreId?: number;
+ lastRestoreId?: number;
+ startedAt?: string;
+ completedAt?: string;
+ updatedAt?: string;
+ options?: StagingSyncOptions;
+ error?: StagingSyncApiError;
+};
+
+type StagingSyncSliceState = {
+ states: Record< number, StagingSyncState >;
+};
+
+const apiTimestampSchema = z
+ .union( [ z.string(), z.number() ] )
+ .nullable()
+ .optional()
+ .transform( ( value ) => {
+ if ( value === undefined || value === null || value === '' ) {
+ return undefined;
+ }
+
+ if ( typeof value === 'string' ) {
+ return value;
+ }
+
+ const milliseconds = value > 10_000_000_000 ? value : value * 1000;
+ const date = new Date( milliseconds );
+ return Number.isNaN( date.getTime() ) ? String( value ) : date.toISOString();
+ } );
+
+const stagingSyncStateResponseSchema = z.object( {
+ status: z.string(),
+ staging_blog_id: z.number().optional(),
+ restore_id: z.number().optional(),
+ last_restore_id: z.number().optional(),
+ production_blog_id: z.number(),
+ started_at: apiTimestampSchema,
+ completed_at: apiTimestampSchema,
+ direction: z.enum( [ 'push', 'pull' ] ).optional(),
+ options: z.unknown().optional(),
+ updated_at: apiTimestampSchema,
+} );
+
+const successResponseSchema = z.object( {
+ success: z.boolean(),
+} );
+
+const createdStagingSiteResponseSchema = z
+ .object( {
+ id: z.number(),
+ name: z.string().optional(),
+ url: z.string().optional(),
+ user_has_permission: z.boolean().optional(),
+ } )
+ .passthrough();
+
+export type CreatedStagingSite = z.infer< typeof createdStagingSiteResponseSchema >;
+
+const initialState: StagingSyncSliceState = {
+ states: {},
+};
+
+const ACTIVE_STAGING_SYNC_STATUSES = new Set< StagingSyncStatus >( [ 'started', 'in-progress' ] );
+
+function getApiErrorStatus( error: unknown ) {
+ if ( error && typeof error === 'object' ) {
+ const status = ( error as { status?: unknown } ).status;
+ if ( typeof status === 'number' ) {
+ return status;
+ }
+
+ const statusCode = ( error as { statusCode?: unknown } ).statusCode;
+ if ( typeof statusCode === 'number' ) {
+ return statusCode;
+ }
+
+ const dataStatus = ( error as { data?: { status?: unknown } } ).data?.status;
+ if ( typeof dataStatus === 'number' ) {
+ return dataStatus;
+ }
+ }
+
+ return undefined;
+}
+
+export function getStagingSyncApiError( error: unknown ): StagingSyncApiError {
+ if ( error instanceof z.ZodError ) {
+ return {
+ code: 'invalid_sync_state_response',
+ message: __( 'The staging sync response was not in the expected format.' ),
+ };
+ }
+
+ if ( error && typeof error === 'object' ) {
+ const code = ( error as { code?: unknown } ).code;
+ const message = ( error as { message?: unknown } ).message;
+
+ return {
+ code: typeof code === 'string' ? code : undefined,
+ message:
+ typeof message === 'string' && message.length > 0
+ ? message
+ : __( 'The staging sync could not be completed.' ),
+ status: getApiErrorStatus( error ),
+ };
+ }
+
+ return {
+ message:
+ error instanceof Error ? error.message : __( 'The staging sync could not be completed.' ),
+ };
+}
+
+function normalizeStatus( status: string ): StagingSyncStatus {
+ if ( status === 'started' || status === 'in-progress' || status === 'completed' ) {
+ return status;
+ }
+
+ return status === 'failed' ? 'failed' : 'in-progress';
+}
+
+function normalizeOptions( options: unknown ): StagingSyncOptions | undefined {
+ if ( Array.isArray( options ) ) {
+ return options.filter( ( option ): option is StagingSyncOption =>
+ ( STAGING_SYNC_OPTION_TOKENS as readonly string[] ).includes( option )
+ );
+ }
+
+ if ( options && typeof options === 'object' ) {
+ const {
+ types,
+ include_paths: includePaths,
+ exclude_paths: excludePaths,
+ } = options as {
+ types?: unknown;
+ include_paths?: unknown;
+ exclude_paths?: unknown;
+ };
+ if ( types === 'paths' && Array.isArray( includePaths ) ) {
+ return {
+ types,
+ include_paths: includePaths.filter(
+ ( includePath ): includePath is string => typeof includePath === 'string'
+ ),
+ exclude_paths: Array.isArray( excludePaths )
+ ? excludePaths.filter(
+ ( excludePath ): excludePath is string => typeof excludePath === 'string'
+ )
+ : undefined,
+ };
+ }
+
+ if ( typeof types === 'string' ) {
+ return normalizeOptions( types.split( ',' ) );
+ }
+ }
+
+ return undefined;
+}
+
+export const startStagingSiteSync = createAsyncThunk<
+ { productionSiteId: number },
+ {
+ productionSite: SyncSite;
+ stagingSite: SyncSite;
+ direction: StagingSyncDirection;
+ options: StagingSyncOptions;
+ allowWooSync?: boolean;
+ },
+ { rejectValue: StagingSyncApiError }
+>(
+ 'stagingSync/start',
+ async (
+ { productionSite, stagingSite, direction, options, allowWooSync },
+ { rejectWithValue }
+ ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return rejectWithValue( {
+ code: 'not_authenticated',
+ message: __( 'Log in to WordPress.com to sync staging sites.' ),
+ status: 401,
+ } );
+ }
+
+ try {
+ const path =
+ direction === 'push'
+ ? `/sites/${ productionSite.id }/staging-site/push-to-staging/${ stagingSite.id }`
+ : `/sites/${ productionSite.id }/staging-site/pull-from-staging/${ stagingSite.id }`;
+ const response = await wpcomClient.req.post( {
+ apiNamespace: 'wpcom/v2',
+ path,
+ body: {
+ options,
+ ...( allowWooSync ? { allow_woo_sync: true } : {} ),
+ },
+ } );
+ successResponseSchema.parse( response );
+
+ return { productionSiteId: productionSite.id };
+ } catch ( error ) {
+ Sentry.captureException( error );
+ console.error( error );
+ return rejectWithValue( getStagingSyncApiError( error ) );
+ }
+ }
+);
+
+export const fetchStagingSiteSyncState = createAsyncThunk<
+ StagingSyncState,
+ { productionSiteId: number },
+ { rejectValue: StagingSyncApiError }
+>( 'stagingSync/fetchState', async ( { productionSiteId }, { rejectWithValue } ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return rejectWithValue( {
+ code: 'not_authenticated',
+ message: __( 'Log in to WordPress.com to sync staging sites.' ),
+ status: 401,
+ } );
+ }
+
+ try {
+ const response = await wpcomClient.req.get( {
+ apiNamespace: 'wpcom/v2',
+ path: `/sites/${ productionSiteId }/staging-site/sync-state`,
+ } );
+
+ if ( ! response ) {
+ return {
+ productionSiteId,
+ status: 'idle',
+ };
+ }
+
+ const parsed = stagingSyncStateResponseSchema.parse( response );
+
+ return {
+ productionSiteId: parsed.production_blog_id,
+ stagingSiteId: parsed.staging_blog_id,
+ status: normalizeStatus( parsed.status ),
+ direction: parsed.direction,
+ restoreId: parsed.restore_id,
+ lastRestoreId: parsed.last_restore_id,
+ startedAt: parsed.started_at,
+ completedAt: parsed.completed_at,
+ updatedAt: parsed.updated_at,
+ options: normalizeOptions( parsed.options ),
+ };
+ } catch ( error ) {
+ const apiError = getStagingSyncApiError( error );
+ if ( apiError.status === 204 ) {
+ return {
+ productionSiteId,
+ status: 'idle',
+ };
+ }
+
+ if ( apiError.status === 404 || apiError.code === 'no_staging_sites' ) {
+ return {
+ productionSiteId,
+ status: 'no-staging',
+ error: apiError,
+ };
+ }
+
+ Sentry.captureException( error );
+ console.error( error );
+ return rejectWithValue( apiError );
+ }
+} );
+
+export const createStagingSite = createAsyncThunk<
+ CreatedStagingSite,
+ { productionSite: SyncSite },
+ { rejectValue: StagingSyncApiError }
+>( 'stagingSync/createStagingSite', async ( { productionSite }, { rejectWithValue } ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return rejectWithValue( {
+ code: 'not_authenticated',
+ message: __( 'Log in to WordPress.com to create a staging site.' ),
+ status: 401,
+ } );
+ }
+
+ try {
+ const response = await wpcomClient.req.post( {
+ apiNamespace: 'wpcom/v2',
+ path: `/sites/${ productionSite.id }/staging-site`,
+ } );
+
+ return createdStagingSiteResponseSchema.parse( response );
+ } catch ( error ) {
+ Sentry.captureException( error );
+ console.error( error );
+ return rejectWithValue( getStagingSyncApiError( error ) );
+ }
+} );
+
+const stagingSyncSlice = createSlice( {
+ name: 'stagingSync',
+ initialState,
+ reducers: {
+ clearStagingSyncState: ( state, action: PayloadAction< { productionSiteId: number } > ) => {
+ delete state.states[ action.payload.productionSiteId ];
+ },
+ markStagingSyncTimedOut: ( state, action: PayloadAction< { productionSiteId: number } > ) => {
+ const currentState = state.states[ action.payload.productionSiteId ];
+ if ( currentState && ACTIVE_STAGING_SYNC_STATUSES.has( currentState.status ) ) {
+ currentState.status = 'timed-out';
+ }
+ },
+ },
+ extraReducers: ( builder ) => {
+ builder
+ .addCase( startStagingSiteSync.pending, ( state, action ) => {
+ const { productionSite, stagingSite, direction, options } = action.meta.arg;
+ state.states[ productionSite.id ] = {
+ productionSiteId: productionSite.id,
+ stagingSiteId: stagingSite.id,
+ status: 'started',
+ direction,
+ options,
+ startedAt: new Date().toISOString(),
+ };
+ } )
+ .addCase( startStagingSiteSync.rejected, ( state, action ) => {
+ const { productionSite, stagingSite, direction, options } = action.meta.arg;
+ state.states[ productionSite.id ] = {
+ productionSiteId: productionSite.id,
+ stagingSiteId: stagingSite.id,
+ status: 'failed',
+ direction,
+ options,
+ error: action.payload ?? getStagingSyncApiError( action.error ),
+ };
+ } )
+ .addCase( fetchStagingSiteSyncState.fulfilled, ( state, action ) => {
+ const currentState = state.states[ action.payload.productionSiteId ];
+ if (
+ action.payload.status === 'idle' &&
+ currentState &&
+ ACTIVE_STAGING_SYNC_STATUSES.has( currentState.status )
+ ) {
+ return;
+ }
+
+ state.states[ action.payload.productionSiteId ] = {
+ ...currentState,
+ ...action.payload,
+ };
+ } )
+ .addCase( fetchStagingSiteSyncState.rejected, ( state, action ) => {
+ const { productionSiteId } = action.meta.arg;
+ state.states[ productionSiteId ] = {
+ ...state.states[ productionSiteId ],
+ productionSiteId,
+ status: 'failed',
+ error: action.payload ?? getStagingSyncApiError( action.error ),
+ };
+ } );
+ },
+} );
+
+export const stagingSyncActions = stagingSyncSlice.actions;
+export const stagingSyncReducer = stagingSyncSlice.reducer;
+
+export const stagingSyncSelectors = {
+ selectState: ( productionSiteId?: number ) => ( state: RootState ) =>
+ productionSiteId ? state.stagingSync.states[ productionSiteId ] : undefined,
+ selectIsProductionSiteSyncing: ( productionSiteId?: number ) => ( state: RootState ) => {
+ const stagingSyncState = productionSiteId
+ ? state.stagingSync.states[ productionSiteId ]
+ : undefined;
+ return stagingSyncState ? ACTIVE_STAGING_SYNC_STATUSES.has( stagingSyncState.status ) : false;
+ },
+ selectIsRemoteSiteEnvironmentSyncing: ( siteId?: number ) => ( state: RootState ) => {
+ if ( ! siteId ) {
+ return false;
+ }
+
+ return Object.values( state.stagingSync.states ).some(
+ ( stagingSyncState ) =>
+ ACTIVE_STAGING_SYNC_STATUSES.has( stagingSyncState.status ) &&
+ ( stagingSyncState.productionSiteId === siteId ||
+ stagingSyncState.stagingSiteId === siteId )
+ );
+ },
+ selectRemoteSiteEnvironmentSyncState: ( siteId?: number ) => ( state: RootState ) => {
+ if ( ! siteId ) {
+ return undefined;
+ }
+
+ return Object.values( state.stagingSync.states ).find(
+ ( stagingSyncState ) =>
+ stagingSyncState.productionSiteId === siteId || stagingSyncState.stagingSiteId === siteId
+ );
+ },
+};
+
+export const stagingSyncThunks = {
+ startStagingSiteSync,
+ fetchStagingSiteSyncState,
+ createStagingSite,
+};
diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts
index fb1a2c358a..8412207051 100644
--- a/apps/studio/src/stores/sync/wpcom-sites.ts
+++ b/apps/studio/src/stores/sync/wpcom-sites.ts
@@ -2,6 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import * as Sentry from '@sentry/electron/renderer';
import { getSyncSupport } from '@studio/common/lib/sync/sync-support';
import {
+ isStagingSiteResponse,
transformSingleSiteResponse,
transformSitesResponse,
} from '@studio/common/lib/sync/transform-sites';
@@ -24,11 +25,58 @@ const SITE_FIELDS = [
'jetpack',
'is_deleted',
'is_a8c',
+ 'is_wpcom_staging_site',
'hosting_provider_guess',
'environment_type',
'icon',
].join( ',' );
+const activeWpcomThemeResponseSchema = z
+ .object( {
+ id: z.string().optional(),
+ name: z.string().optional(),
+ screenshot: z.string().nullable().optional(),
+ is_block_theme: z.boolean().optional(),
+ block_theme: z.boolean().optional(),
+ blockTheme: z.boolean().optional(),
+ supports_menus: z.boolean().optional(),
+ supports_widgets: z.boolean().optional(),
+ } )
+ .passthrough()
+ .transform( ( theme ) => ( {
+ id: theme.id,
+ name: theme.name,
+ screenshotUrl: theme.screenshot || undefined,
+ isBlockTheme: theme.is_block_theme ?? theme.block_theme ?? theme.blockTheme,
+ supportsMenus: theme.supports_menus,
+ supportsWidgets: theme.supports_widgets,
+ } ) );
+
+export type WpcomActiveTheme = z.infer< typeof activeWpcomThemeResponseSchema >;
+
+const wpcomSiteSettingsResponseSchema = z
+ .object( {
+ ID: z.number().optional(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ URL: z.string().optional(),
+ lang: z.string().optional(),
+ locale_variant: z.string().nullable().optional(),
+ settings: z.record( z.string(), z.unknown() ).optional(),
+ } )
+ .passthrough()
+ .transform( ( response ) => ( {
+ id: response.ID,
+ name: response.name,
+ description: response.description,
+ url: response.URL,
+ lang: response.lang,
+ localeVariant: response.locale_variant ?? undefined,
+ settings: response.settings ?? {},
+ } ) );
+
+export type WpcomSiteSettings = z.infer< typeof wpcomSiteSettingsResponseSchema >;
+
export const wpcomSitesApi = createApi( {
reducerPath: 'wpcomSitesApi',
baseQuery: fetchBaseQuery(),
@@ -57,17 +105,17 @@ export const wpcomSitesApi = createApi( {
const allConnectedSites = await getIpcApi().getConnectedWpcomSites();
- // Determine if staging by checking environment_type (can't access parent site's staging IDs without fetching /me/sites)
- const isStaging =
- parsedSite.environment_type === 'staging' ||
- parsedSite.environment_type === 'development';
+ // Single-site responses do not include the parent site's staging ID list.
+ const isStaging = isStagingSiteResponse( parsedSite );
const syncSupport = getSyncSupport(
parsedSite,
allConnectedSites.map( ( { id } ) => id )
);
- const syncSite = transformSingleSiteResponse( parsedSite, syncSupport, isStaging );
+ const syncSite = transformSingleSiteResponse( parsedSite, syncSupport, isStaging, {
+ stagingSiteIds: isStaging ? undefined : parsedSite.options?.wpcom_staging_blog_ids,
+ } );
return { data: syncSite };
} catch ( error ) {
@@ -166,11 +214,13 @@ export const wpcomSitesApi = createApi( {
);
const parsed = sitesEndpointSiteSchema.parse( singleResponse );
const syncSupport = getSyncSupport( parsed, connectedIds );
- const isStaging =
- parsed.environment_type === 'staging' ||
- parsed.environment_type === 'development';
+ const isStaging = isStagingSiteResponse( parsed );
supplementalSites.push(
- transformSingleSiteResponse( parsed, syncSupport, isStaging )
+ transformSingleSiteResponse( parsed, syncSupport, isStaging, {
+ stagingSiteIds: isStaging
+ ? undefined
+ : parsed.options?.wpcom_staging_blog_ids,
+ } )
);
} catch ( error ) {
const status = ( error as { status?: number } )?.status;
@@ -248,15 +298,81 @@ export const wpcomSitesApi = createApi( {
{ type: 'WpComSites', id: arg.siteId },
],
} ),
+ getActiveWpcomTheme: builder.query< WpcomActiveTheme, { siteId: number; userId?: number } >( {
+ queryFn: async ( { siteId } ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return { error: { status: 401, data: 'Not authenticated' } };
+ }
+
+ try {
+ const response = await wpcomClient.req.get( {
+ apiNamespace: 'rest/v1',
+ path: `/sites/${ siteId }/themes/mine`,
+ } );
+
+ return { data: activeWpcomThemeResponseSchema.parse( response ) };
+ } catch ( error ) {
+ Sentry.captureException( error );
+ console.error( error );
+ return {
+ error: {
+ status: 500,
+ data: error,
+ },
+ };
+ }
+ },
+ keepUnusedDataFor: 300,
+ providesTags: ( _result, _error, arg ) => [
+ { type: 'WpComSites', userId: arg.userId },
+ { type: 'WpComSites', id: arg.siteId },
+ ],
+ } ),
+ getWpcomSiteSettings: builder.query< WpcomSiteSettings, { siteId: number; userId?: number } >( {
+ queryFn: async ( { siteId } ) => {
+ const wpcomClient = getWpcomClient();
+ if ( ! wpcomClient ) {
+ return { error: { status: 401, data: 'Not authenticated' } };
+ }
+
+ try {
+ const response = await wpcomClient.req.get( {
+ apiNamespace: 'rest/v1.1',
+ path: `/sites/${ siteId }/settings`,
+ } );
+
+ return { data: wpcomSiteSettingsResponseSchema.parse( response ) };
+ } catch ( error ) {
+ Sentry.captureException( error );
+ console.error( error );
+ return {
+ error: {
+ status: 500,
+ data: error,
+ },
+ };
+ }
+ },
+ keepUnusedDataFor: 300,
+ providesTags: ( _result, _error, arg ) => [
+ { type: 'WpComSites', userId: arg.userId },
+ { type: 'WpComSites', id: arg.siteId },
+ ],
+ } ),
} ),
} );
const {
useGetWpComSitesQuery: useGetWpComSitesQueryBase,
useGetPhpVersionQuery: useGetPhpVersionQueryBase,
+ useGetActiveWpcomThemeQuery: useGetActiveWpcomThemeQueryBase,
+ useGetWpcomSiteSettingsQuery: useGetWpcomSiteSettingsQueryBase,
} = wpcomSitesApi;
// Wrap the query hook with offline check
// Authentication is already handled in queryFn which checks wpcomClient
export const useGetWpComSitesQuery = withOfflineCheck( useGetWpComSitesQueryBase );
export const useGetPhpVersionQuery = withOfflineCheck( useGetPhpVersionQueryBase );
+export const useGetActiveWpcomThemeQuery = withOfflineCheck( useGetActiveWpcomThemeQueryBase );
+export const useGetWpcomSiteSettingsQuery = withOfflineCheck( useGetWpcomSiteSettingsQueryBase );
diff --git a/apps/studio/src/tests/site-server.test.ts b/apps/studio/src/tests/site-server.test.ts
index fa1c963549..88b30545f5 100644
--- a/apps/studio/src/tests/site-server.test.ts
+++ b/apps/studio/src/tests/site-server.test.ts
@@ -140,6 +140,36 @@ describe( 'SiteServer', () => {
} );
describe( 'start', () => {
+ beforeEach( () => {
+ mockStartServer.mockReset();
+ mockStartServer.mockResolvedValue( {
+ url: 'http://localhost:1234',
+ options: { port: 1234, phpVersion: '8.0' },
+ _internal: { mode: 'wordpress', port: 1234 },
+ } );
+ } );
+
+ it( 'transitions stopped details to running after the CLI server starts', async () => {
+ const server = SiteServer.register( {
+ id: 'start-id',
+ name: 'start-name',
+ path: 'start-path',
+ port: 9191,
+ adminPassword: 'test-password',
+ phpVersion: '8.4',
+ running: false,
+ themeDetails: undefined,
+ } );
+
+ await server.start();
+
+ expect( server.details.running ).toBe( true );
+ if ( server.details.running ) {
+ expect( server.details.url ).toBe( 'http://localhost:9191' );
+ }
+ expect( server.server.url ).toBe( 'http://localhost:9191' );
+ } );
+
it( 'should throw if the server starts with a non-WordPress mode', async () => {
mockStartServer.mockRejectedValue(
new Error(
diff --git a/package-lock.json b/package-lock.json
index 6e8c16002e..6157bee657 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -546,6 +546,8 @@
"version": "1.9.0",
"license": "GPL-2.0-or-later",
"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",
@@ -1137,6 +1139,147 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/@automattic/agenttic-client": {
+ "version": "0.1.63",
+ "resolved": "https://registry.npmjs.org/@automattic/agenttic-client/-/agenttic-client-0.1.63.tgz",
+ "integrity": "sha512-Y1gnb+pDrQUJ3CA46W34vGhh9l/ofzkcLIYJJhjwLfqY0kPnXvinIiTPrlogMusTOxctbE1sD2R+h6fGL/byrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "^18.0.0",
+ "react": "^18.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "optionalDependencies": {
+ "marked": "^16.2.1"
+ },
+ "peerDependencies": {
+ "@wordpress/api-fetch": "^6.0.0 || ^7.0.0",
+ "react": "^18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@wordpress/api-fetch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@automattic/agenttic-client/node_modules/marked": {
+ "version": "16.4.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@automattic/agenttic-ui": {
+ "version": "0.1.63",
+ "resolved": "https://registry.npmjs.org/@automattic/agenttic-ui/-/agenttic-ui-0.1.63.tgz",
+ "integrity": "sha512-rb2bvnQLzZsMEA3qkUk7wN5lYrfjFzo4v3q8RXR7ppO2C1mgez70JcAvzCNC6ib2QuTAruTYOmpwRWtNgrf8LA==",
+ "license": "MIT",
+ "dependencies": {
+ "@automattic/charts": ">=0.58.0 <1.0.0",
+ "@floating-ui/react-dom": "^2.1.6",
+ "@radix-ui/react-scroll-area": "^1.2.9",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@visx/xychart": "^3.12.0",
+ "@wordpress/i18n": "^6.1.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.23.0",
+ "lucide-react": "^0.525.0",
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "react-markdown": "^10.1.0",
+ "react-textarea-autosize": "^8.5.9",
+ "remark-gfm": "^4.0.1",
+ "unified": "^11.0.5",
+ "use-debounce": "^10.0.6"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "optionalDependencies": {
+ "marked": "^16.2.1"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/@automattic/agenttic-ui/node_modules/@floating-ui/react-dom": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
+ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.6"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@automattic/agenttic-ui/node_modules/framer-motion": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
+ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.38.0",
+ "motion-utils": "^12.36.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@automattic/agenttic-ui/node_modules/marked": {
+ "version": "16.4.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
+ "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@automattic/agenttic-ui/node_modules/motion-dom": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
+ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.36.0"
+ }
+ },
+ "node_modules/@automattic/agenttic-ui/node_modules/motion-utils": {
+ "version": "12.36.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
+ "license": "MIT"
+ },
"node_modules/@automattic/babel-plugin-i18n-calypso": {
"version": "1.2.0",
"dev": true,
@@ -1179,6 +1322,140 @@
],
"license": "MIT"
},
+ "node_modules/@automattic/charts": {
+ "version": "0.59.0",
+ "resolved": "https://registry.npmjs.org/@automattic/charts/-/charts-0.59.0.tgz",
+ "integrity": "sha512-DLGIm2v9lkdKQfrkPw06HbwJGSZqSK/glRFcZXyh2FJiSvRF54T0lwjuqw6fCNzOgIlosUC7O2uRciXZP6vP5w==",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@automattic/number-formatters": "^1.1.2",
+ "@babel/runtime": "7.28.6",
+ "@react-spring/web": "9.7.5",
+ "@visx/annotation": "^3.12.0",
+ "@visx/axis": "^3.12.0",
+ "@visx/curve": "^3.12.0",
+ "@visx/event": "^3.12.0",
+ "@visx/gradient": "^3.12.0",
+ "@visx/grid": "^3.12.0",
+ "@visx/group": "^3.12.0",
+ "@visx/legend": "^3.12.0",
+ "@visx/pattern": "^3.12.0",
+ "@visx/responsive": "^3.12.0",
+ "@visx/scale": "^3.12.0",
+ "@visx/shape": "^3.12.0",
+ "@visx/text": "^3.12.0",
+ "@visx/tooltip": "^3.12.0",
+ "@visx/vendor": "^3.12.0",
+ "@visx/xychart": "^3.12.0",
+ "@wordpress/i18n": "^6.0.0",
+ "@wordpress/theme": "0.9.0",
+ "@wordpress/ui": "0.9.0",
+ "clsx": "2.1.1",
+ "date-fns": "^4.1.0",
+ "deepmerge": "4.3.1",
+ "fast-deep-equal": "3.1.3",
+ "gridicons": "3.4.2",
+ "react-google-charts": "^5.2.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.10.0"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@automattic/charts/node_modules/@babel/runtime": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@automattic/charts/node_modules/@wordpress/icons": {
+ "version": "12.2.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-12.2.0.tgz",
+ "integrity": "sha512-Fiw7bmfHDNPjTdCrBF23/9K0VN/GUi73d2ZPZaeWdXhTmIX62T9KYvb1c+WnlBkX7GpXgJO6Q8mypQCY9mw5SQ==",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/element": "^6.44.0",
+ "@wordpress/primitives": "^4.44.0",
+ "change-case": "4.1.2"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
+ "node_modules/@automattic/charts/node_modules/@wordpress/theme": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-0.9.0.tgz",
+ "integrity": "sha512-jxskNZVvWHIswQvWvswaNIAkBpXwdFcocBYxTWQnYgvb0QAEYsKsnqYMulZPrz/Dk4c+GF7ptwdLxb3rry9tcg==",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/element": "^6.42.0",
+ "@wordpress/private-apis": "^1.42.0",
+ "colorjs.io": "^0.6.0",
+ "memize": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0",
+ "stylelint": "^16.8.2"
+ },
+ "peerDependenciesMeta": {
+ "stylelint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@automattic/charts/node_modules/@wordpress/ui": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.9.0.tgz",
+ "integrity": "sha512-PXx0CU5ngJOaC69ylhyyS33Ac4njVudGMkrPjXuRd6cXZeizD3q6KO0ws1ECtm4FYlrWv5YvYyNhT5salP9hTg==",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@base-ui/react": "^1.2.0",
+ "@wordpress/a11y": "^4.42.0",
+ "@wordpress/compose": "^7.42.0",
+ "@wordpress/element": "^6.42.0",
+ "@wordpress/i18n": "^6.15.0",
+ "@wordpress/icons": "^12.0.0",
+ "@wordpress/keycodes": "^4.42.0",
+ "@wordpress/primitives": "^4.42.0",
+ "@wordpress/private-apis": "^1.42.0",
+ "@wordpress/theme": "^0.9.0",
+ "clsx": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20.10.0",
+ "npm": ">=10.2.3"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
+ "node_modules/@automattic/charts/node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/@automattic/color-studio": {
"version": "4.1.0",
"dev": true,
@@ -1201,6 +1478,17 @@
}
}
},
+ "node_modules/@automattic/number-formatters": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@automattic/number-formatters/-/number-formatters-1.1.7.tgz",
+ "integrity": "sha512-dw+FjiixPhUcb6oCRLbBghVNb92sYKe8LYJcXnC0fRwLoq58AcSFXotqErwg7HJ2N4Zc4aBpF2YJaOThiU64PA==",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@wordpress/date": "^5.19.0",
+ "debug": "^4.4.0",
+ "tslib": "^2.5.0"
+ }
+ },
"node_modules/@automattic/wp-babel-makepot": {
"version": "1.2.0",
"dev": true,
@@ -14340,6 +14628,90 @@
"integrity": "sha512-VgnAj6tIAhJhZdJ8/IpxdatM8G4OD3VWGlp6xIxUGENZlpbob9Ty4VVdC1FIEp0aK6DBscDDjyzy5FB60TuNqg==",
"license": "MIT"
},
+ "node_modules/@types/d3-array": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
+ "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-delaunay": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz",
+ "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-format": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz",
+ "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz",
+ "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "1.3.12",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz",
+ "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "^1"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz",
+ "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-time-format": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz",
+ "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-voronoi": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@types/d3-voronoi/-/d3-voronoi-1.1.12.tgz",
+ "integrity": "sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==",
+ "license": "MIT"
+ },
"node_modules/@types/debug": {
"version": "4.1.12",
"license": "MIT",
@@ -14400,6 +14772,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/gradient-parser": {
"version": "1.1.0",
"license": "MIT"
@@ -14471,6 +14849,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "license": "MIT"
+ },
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@@ -15192,46 +15576,417 @@
"win32"
]
},
- "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
- "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
+ "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@use-gesture/core": {
+ "version": "10.3.1",
+ "license": "MIT"
+ },
+ "node_modules/@use-gesture/react": {
+ "version": "10.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "@use-gesture/core": "10.3.1"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0"
+ }
+ },
+ "node_modules/@visx/annotation": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/annotation/-/annotation-3.12.0.tgz",
+ "integrity": "sha512-ZH6Y4jfrb47iEUV9O2itU9TATE5IPzhs5qvP6J7vmv26qkqwDcuE7xN3S3l9R70WjyEKGbpO8js4EijA3FJWkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/drag": "3.12.0",
+ "@visx/group": "3.12.0",
+ "@visx/text": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.5.10",
+ "react-use-measure": "^2.0.4"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/axis": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/axis/-/axis-3.12.0.tgz",
+ "integrity": "sha512-8MoWpfuaJkhm2Yg+HwyytK8nk+zDugCqTT/tRmQX7gW4LYrHYLXFUXOzbDyyBakCVaUbUaAhVFxpMASJiQKf7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/group": "3.12.0",
+ "@visx/point": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "@visx/shape": "3.12.0",
+ "@visx/text": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.6.0"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/bounds": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/bounds/-/bounds-3.12.0.tgz",
+ "integrity": "sha512-peAlNCUbYaaZ0IO6c1lDdEAnZv2iGPDiLIM8a6gu7CaMhtXZJkqrTh+AjidNcIqITktrICpGxJE/Qo9D099dvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "prop-types": "^15.5.10"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0",
+ "react-dom": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/curve": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-3.12.0.tgz",
+ "integrity": "sha512-Ng1mefXIzoIoAivw7dJ+ZZYYUbfuwXgZCgQynShr6ZIVw7P4q4HeQfJP3W24ON+1uCSrzoycHSXRelhR9SBPcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-shape": "^1.3.1",
+ "d3-shape": "^1.0.6"
+ }
+ },
+ "node_modules/@visx/drag": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/drag/-/drag-3.12.0.tgz",
+ "integrity": "sha512-LXOoPVw//YPjpYhDJYBsCYDuv1QimsXjDV98duH0aCy4V94ediXMQpe2wHq4pnlDobLEB71FjOZMFrbFmqtERg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/event": "3.12.0",
+ "@visx/point": "3.12.0",
+ "prop-types": "^15.5.10"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/event": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/event/-/event-3.12.0.tgz",
+ "integrity": "sha512-9Lvw6qJ0Fi+y1vsC1WspfdIKCxHTb7oy59Uql1uBdPGT8zChP0vuxW0jQNQRDbKgoefj4pCXAFi8+MF1mEtVTw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/point": "3.12.0"
+ }
+ },
+ "node_modules/@visx/glyph": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/glyph/-/glyph-3.12.0.tgz",
+ "integrity": "sha512-E9ST9MoPNyXQzjZxYYAGXT4CbBpnB90Qhx8UvUUM2/n/SZUNeH+m6UiB/CzT0jGK2b0lPHF91mlOiQ8JXBRhYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-shape": "^1.3.1",
+ "@types/react": "*",
+ "@visx/group": "3.12.0",
+ "classnames": "^2.3.1",
+ "d3-shape": "^1.2.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/gradient": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/gradient/-/gradient-3.12.0.tgz",
+ "integrity": "sha512-QRatjjdUEPbcp4pqRca1JlChpAnmmIAO3r3ZscLK7D1xEIANlIjzjl3uNgrmseYmBAYyPCcJH8Zru07R97ovOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "prop-types": "^15.5.7"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/grid": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-3.12.0.tgz",
+ "integrity": "sha512-L4ex2ooSYhwNIxJ3XFIKRhoSvEGjPc2Y3YCrtNB4TV5Ofdj4q0UMOsxfrH23Pr8HSHuQhb6VGMgYoK0LuWqDmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/curve": "3.12.0",
+ "@visx/group": "3.12.0",
+ "@visx/point": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "@visx/shape": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/group": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/group/-/group-3.12.0.tgz",
+ "integrity": "sha512-Dye8iS1alVXPv7nj/7M37gJe6sSKqJLH7x6sEWAsRQ9clI0kFvjbKcKgF+U3aAVQr0NCohheFV+DtR8trfK/Ag==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/legend": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/legend/-/legend-3.12.0.tgz",
+ "integrity": "sha512-Tr6hdauEDXRXVNeNgIQ9JtCCrxn8Fbr8UCVlO9XsSxenk2hBC/2PIY5QPzpnKFEEEuH/C8vhj8T0JfFZV+D9zQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/group": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.5.10"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/pattern": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/pattern/-/pattern-3.12.0.tgz",
+ "integrity": "sha512-ZkNA/2TkULNiiY4cw2IkuQcQRp9zI3SQ0/JoZMQ+UmUvY5RNBcsdTmic7649egHq0FRYCbY0DDQVJicccW5JUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.5.10"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/point": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/point/-/point-3.12.0.tgz",
+ "integrity": "sha512-I6UrHoJAEVbx3RORQNupgTiX5EzjuZpiwLPxn8L2mI5nfERotPKi1Yus12Cq2WtXqEBR/WgqTnoImlqOXBykcA==",
+ "license": "MIT"
+ },
+ "node_modules/@visx/react-spring": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/react-spring/-/react-spring-3.12.0.tgz",
+ "integrity": "sha512-ehtmjFrUQx3g0mZ684M4LgI9UfQ84ZWD/m9tKfvXhEm1Vl8D4AjaZ4af1tTOg9S7vk2VlpxvVOVN7+t5pu0nSA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/axis": "3.12.0",
+ "@visx/grid": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "@visx/text": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "@react-spring/web": "^9.4.5",
+ "react": "^16.3.0-0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@visx/responsive": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-3.12.0.tgz",
+ "integrity": "sha512-GV1BTYwAGlk/K5c9vH8lS2syPnTuIqEacI7L6LRPbsuaLscXMNi+i9fZyzo0BWvAdtRV8v6Urzglo++lvAXT1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "^4.14.172",
+ "@types/react": "*",
+ "lodash": "^4.17.21",
+ "prop-types": "^15.6.1"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/scale": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-3.12.0.tgz",
+ "integrity": "sha512-+ubijrZ2AwWCsNey0HGLJ0YKNeC/XImEFsr9rM+Uef1CM3PNM43NDdNTrdBejSlzRq0lcfQPWYMYQFSlkLcPOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@visx/vendor": "3.12.0"
+ }
+ },
+ "node_modules/@visx/shape": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-3.12.0.tgz",
+ "integrity": "sha512-/1l0lrpX9tPic6SJEalryBKWjP/ilDRnQA+BGJTI1tj7i23mJ/J0t4nJHyA1GrL4QA/bM/qTJ35eyz5dEhJc4g==",
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "@types/d3-path": "^1.0.8",
+ "@types/d3-shape": "^1.3.1",
+ "@types/lodash": "^4.14.172",
+ "@types/react": "*",
+ "@visx/curve": "3.12.0",
+ "@visx/group": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "classnames": "^2.3.1",
+ "d3-path": "^1.0.5",
+ "d3-shape": "^1.2.0",
+ "lodash": "^4.17.21",
+ "prop-types": "^15.5.10"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
},
- "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
- "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
- "cpu": [
- "x64"
- ],
- "dev": true,
+ "node_modules/@visx/text": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/text/-/text-3.12.0.tgz",
+ "integrity": "sha512-0rbDYQlbuKPhBqXkkGYKFec1gQo05YxV45DORzr6hCyaizdJk1G+n9VkuKSHKBy1vVQhBA0W3u/WXd7tiODQPA==",
"license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
+ "dependencies": {
+ "@types/lodash": "^4.14.172",
+ "@types/react": "*",
+ "classnames": "^2.3.1",
+ "lodash": "^4.17.21",
+ "prop-types": "^15.7.2",
+ "reduce-css-calc": "^1.3.0"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
},
- "node_modules/@use-gesture/core": {
- "version": "10.3.1",
- "license": "MIT"
+ "node_modules/@visx/tooltip": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/tooltip/-/tooltip-3.12.0.tgz",
+ "integrity": "sha512-pWhsYhgl0Shbeqf80qy4QCB6zpq6tQtMQQxKlh3UiKxzkkfl+Metaf9p0/S0HexNi4vewOPOo89xWx93hBeh3A==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*",
+ "@visx/bounds": "3.12.0",
+ "classnames": "^2.3.1",
+ "prop-types": "^15.5.10",
+ "react-use-measure": "^2.0.4"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0",
+ "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
+ "node_modules/@visx/vendor": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-3.12.0.tgz",
+ "integrity": "sha512-SVO+G0xtnL9dsNpGDcjCgoiCnlB3iLSM9KLz1sLbSrV7RaVXwY3/BTm2X9OWN1jH2a9M+eHt6DJ6sE6CXm4cUg==",
+ "license": "MIT and ISC",
+ "dependencies": {
+ "@types/d3-array": "3.0.3",
+ "@types/d3-color": "3.1.0",
+ "@types/d3-delaunay": "6.0.1",
+ "@types/d3-format": "3.0.1",
+ "@types/d3-geo": "3.1.0",
+ "@types/d3-interpolate": "3.0.1",
+ "@types/d3-scale": "4.0.2",
+ "@types/d3-time": "3.0.0",
+ "@types/d3-time-format": "2.1.0",
+ "d3-array": "3.2.1",
+ "d3-color": "3.1.0",
+ "d3-delaunay": "6.0.2",
+ "d3-format": "3.1.0",
+ "d3-geo": "3.1.0",
+ "d3-interpolate": "3.0.1",
+ "d3-scale": "4.0.2",
+ "d3-time": "3.1.0",
+ "d3-time-format": "4.1.0",
+ "internmap": "2.0.3"
+ }
+ },
+ "node_modules/@visx/voronoi": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/voronoi/-/voronoi-3.12.0.tgz",
+ "integrity": "sha512-U3HWu6g5UjQchFDq8k/A4U9WrlN+80rAFPdGOUvIGOueQw9RmlZlNaeg8IJcQr2yk1s4O/VSpt3nR82zdINWMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-voronoi": "^1.1.9",
+ "@types/react": "*",
+ "classnames": "^2.3.1",
+ "d3-voronoi": "^1.1.2",
+ "prop-types": "^15.6.1"
+ },
+ "peerDependencies": {
+ "react": "^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
},
- "node_modules/@use-gesture/react": {
- "version": "10.3.1",
+ "node_modules/@visx/xychart": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/@visx/xychart/-/xychart-3.12.0.tgz",
+ "integrity": "sha512-itJ7qvj/STpVmHesVyo2vPOataBM1mgSaf9R6/s4Bpe340wZldfCJ+IqRcNgdtbBagz1Hlr/sRnla4tWE2yw9A==",
"license": "MIT",
"dependencies": {
- "@use-gesture/core": "10.3.1"
+ "@types/lodash": "^4.14.172",
+ "@types/react": "*",
+ "@visx/annotation": "3.12.0",
+ "@visx/axis": "3.12.0",
+ "@visx/event": "3.12.0",
+ "@visx/glyph": "3.12.0",
+ "@visx/grid": "3.12.0",
+ "@visx/react-spring": "3.12.0",
+ "@visx/responsive": "3.12.0",
+ "@visx/scale": "3.12.0",
+ "@visx/shape": "3.12.0",
+ "@visx/text": "3.12.0",
+ "@visx/tooltip": "3.12.0",
+ "@visx/vendor": "3.12.0",
+ "@visx/voronoi": "3.12.0",
+ "classnames": "^2.3.1",
+ "d3-interpolate-path": "2.2.1",
+ "d3-shape": "^2.0.0",
+ "lodash": "^4.17.21",
+ "mitt": "^2.1.0",
+ "prop-types": "^15.6.2"
},
"peerDependencies": {
- "react": ">= 16.8.0"
+ "@react-spring/web": "^9.4.5",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@visx/xychart/node_modules/d3-shape": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz",
+ "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1 - 2"
}
},
"node_modules/@vitejs/plugin-react": {
@@ -18563,6 +19318,18 @@
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"license": "MIT"
},
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@@ -19390,6 +20157,139 @@
"version": "2.1.1",
"license": "ISC"
},
+ "node_modules/d3-array": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz",
+ "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-delaunay": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz",
+ "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==",
+ "license": "ISC",
+ "dependencies": {
+ "delaunator": "5"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
+ "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-geo": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz",
+ "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.5.0 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate-path": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.2.1.tgz",
+ "integrity": "sha512-6qLLh/KJVzls0XtMsMpcxhqMhgVEN7VIbR/6YGZe2qlS8KDgyyVB20XcmGnDyB051HcefQXM/Tppa9vcANEA4Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-path": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "d3-path": "1"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-voronoi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
+ "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@@ -19572,6 +20472,15 @@
"node": ">= 14"
}
},
+ "node_modules/delaunator": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
+ "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
+ "license": "ISC",
+ "dependencies": {
+ "robust-predicates": "^3.0.2"
+ }
+ },
"node_modules/delayed-stream": {
"version": "1.0.0",
"license": "MIT",
@@ -22946,6 +23855,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/gridicons": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/gridicons/-/gridicons-3.4.2.tgz",
+ "integrity": "sha512-KC2BzPDh3F0vJzYa7KYBWJOO9gTHoKoFiHNazZEU9Gq2jIJ2zObOA67wlZjZkPHPCjZiLQrko3AYFLrMrHXKrA==",
+ "license": "GPL-2.0+",
+ "dependencies": {
+ "prop-types": "^15.5.7"
+ },
+ "peerDependencies": {
+ "react": "15 - 18"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"license": "MIT",
@@ -23667,6 +24588,15 @@
"version": "1.14.1",
"license": "0BSD"
},
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/interpret": {
"version": "3.1.1",
"dev": true,
@@ -24994,6 +25924,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.525.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
+ "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"license": "MIT",
@@ -25133,6 +26072,12 @@
"node": ">=10"
}
},
+ "node_modules/math-expression-evaluator": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz",
+ "integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@@ -26180,6 +27125,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/mitt": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-2.1.0.tgz",
+ "integrity": "sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==",
+ "license": "MIT"
+ },
"node_modules/mkdirp": {
"version": "1.0.4",
"dev": true,
@@ -28563,6 +29514,16 @@
"react-dom": ">=16.4.0"
}
},
+ "node_modules/react-google-charts": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/react-google-charts/-/react-google-charts-5.2.1.tgz",
+ "integrity": "sha512-mCbPiObP8yWM5A9ogej7Qp3/HX4EzOwuEzUYvcfHtL98Xt4V/brD14KgfDzSNNtyD48MNXCpq5oVaYKt0ykQUQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -28694,6 +29655,38 @@
}
}
},
+ "node_modules/react-textarea-autosize": {
+ "version": "8.5.9",
+ "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
+ "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "use-composed-ref": "^1.3.0",
+ "use-latest": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/react-use-measure": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
+ "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.13",
+ "react-dom": ">=16.13"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-binary-file-arch": {
"version": "1.0.6",
"dev": true,
@@ -28883,6 +29876,32 @@
"node": ">=8"
}
},
+ "node_modules/reduce-css-calc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
+ "integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^0.4.2",
+ "math-expression-evaluator": "^1.2.14",
+ "reduce-function-call": "^1.0.1"
+ }
+ },
+ "node_modules/reduce-css-calc/node_modules/balanced-match": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
+ "integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==",
+ "license": "MIT"
+ },
+ "node_modules/reduce-function-call": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
+ "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT"
@@ -29250,6 +30269,12 @@
"node": ">=8.0"
}
},
+ "node_modules/robust-predicates": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
+ "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
+ "license": "Unlicense"
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -31628,7 +32653,9 @@
}
},
"node_modules/unified": {
- "version": "11.0.4",
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
@@ -31933,6 +32960,63 @@
}
}
},
+ "node_modules/use-composed-ref": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
+ "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-debounce": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
+ "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
+ "node_modules/use-isomorphic-layout-effect": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
+ "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-latest": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
+ "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "use-isomorphic-layout-effect": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/use-memo-one": {
"version": "1.1.3",
"license": "MIT",
diff --git a/tools/common/lib/sync/sync-api.ts b/tools/common/lib/sync/sync-api.ts
index 1c946bd0bd..c47404077f 100644
--- a/tools/common/lib/sync/sync-api.ts
+++ b/tools/common/lib/sync/sync-api.ts
@@ -25,6 +25,7 @@ const SITE_FIELDS = [
'jetpack',
'is_deleted',
'is_a8c',
+ 'is_wpcom_staging_site',
'hosting_provider_guess',
'environment_type',
].join( ',' );
diff --git a/tools/common/lib/sync/transform-sites.ts b/tools/common/lib/sync/transform-sites.ts
index 3e69d677ab..e8ba1a4a6c 100644
--- a/tools/common/lib/sync/transform-sites.ts
+++ b/tools/common/lib/sync/transform-sites.ts
@@ -2,10 +2,20 @@ import { sitesEndpointSiteSchema } from '@studio/common/types/sync';
import { getSyncSupport, isPressableSite } from './sync-support';
import type { SitesEndpointSite, SyncSite, SyncSupport } from '@studio/common/types/sync';
+const STAGING_SITE_FEATURE = 'staging-sites';
+
+export function isStagingSiteResponse( site: Pick< SitesEndpointSite, 'is_wpcom_staging_site' > ) {
+ return site.is_wpcom_staging_site === true;
+}
+
export function transformSingleSiteResponse(
site: SitesEndpointSite,
syncSupport: SyncSupport,
- isStaging: boolean
+ isStaging: boolean,
+ relationship?: {
+ productionSiteId?: number;
+ stagingSiteIds?: number[];
+ }
): SyncSite {
return {
id: site.ID,
@@ -13,7 +23,12 @@ export function transformSingleSiteResponse(
name: site.name,
url: site.URL,
isStaging,
+ productionSiteId: relationship?.productionSiteId,
+ stagingSiteIds: relationship?.stagingSiteIds,
isPressable: isPressableSite( site ),
+ isWpcomAtomic: site.is_wpcom_atomic,
+ canManageOptions: site.capabilities?.manage_options,
+ hasStagingSiteFeature: site.plan?.features.active.includes( STAGING_SITE_FEATURE ),
environmentType: site.environment_type,
syncSupport,
lastPullTimestamp: null,
@@ -51,9 +66,22 @@ export function transformSitesResponse(
}
}, [] );
- const allStagingSiteIds = validatedSites.flatMap(
- ( site ) => site.options?.wpcom_staging_blog_ids ?? []
- );
+ const stagingSiteIdsByProductionSiteId = new Map< number, number[] >();
+ const productionSiteIdByStagingSiteId = new Map< number, number >();
+
+ validatedSites.forEach( ( site ) => {
+ const stagingSiteIds = site.options?.wpcom_staging_blog_ids ?? [];
+ if ( stagingSiteIds.length === 0 ) {
+ return;
+ }
+
+ stagingSiteIdsByProductionSiteId.set( site.ID, stagingSiteIds );
+ stagingSiteIds.forEach( ( stagingSiteId ) => {
+ if ( ! productionSiteIdByStagingSiteId.has( stagingSiteId ) ) {
+ productionSiteIdByStagingSiteId.set( stagingSiteId, site.ID );
+ }
+ } );
+ } );
return validatedSites
.filter( ( site ) => ! site.is_a8c )
@@ -63,9 +91,14 @@ export function transformSitesResponse(
( connectedSiteIds.length > 0 && connectedSiteIds.some( ( id ) => id === site.ID ) )
)
.map( ( site ) => {
- const isStaging = allStagingSiteIds.includes( site.ID );
+ const productionSiteId = productionSiteIdByStagingSiteId.get( site.ID );
+ const stagingSiteIds = stagingSiteIdsByProductionSiteId.get( site.ID );
+ const isStaging = productionSiteId !== undefined || isStagingSiteResponse( site );
const syncSupport = getSyncSupport( site, connectedSiteIds );
- return transformSingleSiteResponse( site, syncSupport, isStaging );
+ return transformSingleSiteResponse( site, syncSupport, isStaging, {
+ productionSiteId,
+ stagingSiteIds,
+ } );
} );
}
diff --git a/tools/common/lib/tests/transform-sites.test.ts b/tools/common/lib/tests/transform-sites.test.ts
new file mode 100644
index 0000000000..d18349f69c
--- /dev/null
+++ b/tools/common/lib/tests/transform-sites.test.ts
@@ -0,0 +1,117 @@
+import { transformSitesResponse } from '../sync/transform-sites';
+
+const createRawSite = ( {
+ ID,
+ name,
+ url,
+ isWpcomStagingSite = false,
+ wpcomStagingBlogIds = [],
+ environmentType = 'production',
+}: {
+ ID: number;
+ name: string;
+ url?: string;
+ isWpcomStagingSite?: boolean;
+ wpcomStagingBlogIds?: number[];
+ environmentType?: 'production' | 'staging' | 'development';
+} ) => ( {
+ ID,
+ is_wpcom_atomic: true,
+ name,
+ URL: url ?? `https://${ name.toLowerCase().replace( /\s+/g, '-' ) }.example`,
+ jetpack: false,
+ is_deleted: false,
+ is_wpcom_staging_site: isWpcomStagingSite,
+ environment_type: environmentType,
+ options: {
+ created_at: '2026-01-01T00:00:00+00:00',
+ wpcom_staging_blog_ids: wpcomStagingBlogIds,
+ software_version: '6.9.4',
+ },
+ capabilities: {
+ manage_options: true,
+ },
+ plan: {
+ features: {
+ active: [ 'staging-sites' ],
+ },
+ product_id: 1,
+ product_name_short: 'Business',
+ product_slug: 'business',
+ },
+} );
+
+describe( 'transformSitesResponse', () => {
+ it( 'preserves WordPress.com production and staging relationships', () => {
+ const sites = transformSitesResponse( [
+ createRawSite( {
+ ID: 101,
+ name: 'Auro Atelier',
+ wpcomStagingBlogIds: [ 202 ],
+ } ),
+ createRawSite( {
+ ID: 202,
+ name: 'Auro Atelier Staging',
+ isWpcomStagingSite: true,
+ environmentType: 'staging',
+ } ),
+ ] );
+
+ expect( sites ).toHaveLength( 2 );
+ expect( sites[ 0 ] ).toEqual(
+ expect.objectContaining( {
+ id: 101,
+ isStaging: false,
+ stagingSiteIds: [ 202 ],
+ productionSiteId: undefined,
+ isWpcomAtomic: true,
+ canManageOptions: true,
+ hasStagingSiteFeature: true,
+ } )
+ );
+ expect( sites[ 1 ] ).toEqual(
+ expect.objectContaining( {
+ id: 202,
+ isStaging: true,
+ productionSiteId: 101,
+ stagingSiteIds: undefined,
+ } )
+ );
+ } );
+
+ it( 'uses the explicit WordPress.com staging site flag', () => {
+ const sites = transformSitesResponse( [
+ createRawSite( {
+ ID: 101,
+ name: 'My Store',
+ url: 'https://staging-1234-mystore.wpcomstaging.com',
+ isWpcomStagingSite: true,
+ } ),
+ ] );
+
+ expect( sites[ 0 ] ).toEqual(
+ expect.objectContaining( {
+ id: 101,
+ isStaging: true,
+ } )
+ );
+ } );
+
+ it( 'does not infer staging from a WordPress.com staging hostname', () => {
+ const sites = transformSitesResponse( [
+ createRawSite( {
+ ID: 101,
+ name: 'My Store',
+ url: 'https://store.wpcomstaging.com',
+ isWpcomStagingSite: false,
+ } ),
+ ] );
+
+ expect( sites[ 0 ] ).toEqual(
+ expect.objectContaining( {
+ id: 101,
+ isStaging: false,
+ } )
+ );
+ } );
+} );
diff --git a/tools/common/types/sync.ts b/tools/common/types/sync.ts
index b1c8ef77b0..7b14951332 100644
--- a/tools/common/types/sync.ts
+++ b/tools/common/types/sync.ts
@@ -9,6 +9,7 @@ export const sitesEndpointSiteSchema = z.object( {
jetpack: z.boolean().optional(),
is_deleted: z.boolean(),
hosting_provider_guess: z.string().optional(),
+ is_wpcom_staging_site: z.boolean().optional(),
environment_type: z
.enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] )
.nullable()
@@ -78,7 +79,12 @@ export const syncSiteSchema = z.object( {
name: z.string(),
url: z.string(),
isStaging: z.boolean(),
+ productionSiteId: z.number().optional(),
+ stagingSiteIds: z.array( z.number() ).optional(),
isPressable: z.boolean(),
+ isWpcomAtomic: z.boolean().optional(),
+ canManageOptions: z.boolean().optional(),
+ hasStagingSiteFeature: z.boolean().optional(),
environmentType: z.string().nullable().optional(),
syncSupport: z.enum( syncSupportValues ),
lastPullTimestamp: z.string().nullable(),