diff --git a/apps/ui/src/components/connect-site-picker/index.tsx b/apps/ui/src/components/connect-site-picker/index.tsx
new file mode 100644
index 0000000000..a0c1d42573
--- /dev/null
+++ b/apps/ui/src/components/connect-site-picker/index.tsx
@@ -0,0 +1,336 @@
+import { Spinner, VisuallyHidden } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { external, search } from '@wordpress/icons';
+import { Badge, Button, Icon } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useMemo, useState } from 'react';
+import { useConnector } from '@/data/core';
+import { useUserLocale } from '@/data/queries/use-user-locale';
+import { useOffline } from '@/hooks/use-offline';
+import { getLocalizedLink } from '@/lib/docs-links';
+import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation';
+import styles from './style.module.css';
+import type { SyncSite } from '@/data/core';
+
+const createWpcomSiteUrl = new URL( 'https://wordpress.com/setup/new-hosted-site' );
+createWpcomSiteUrl.searchParams.set( 'ref', 'studio' );
+createWpcomSiteUrl.searchParams.set( 'section', 'studio-sync' );
+createWpcomSiteUrl.searchParams.set( 'showDomainStep', 'true' );
+
+function getEnvironmentLabel( site: SyncSite ): string {
+ if ( site.isPressable && site.environmentType === 'development' ) return __( 'Development' );
+ if ( site.isPressable && site.environmentType === 'staging' ) return __( 'Staging' );
+ if ( site.isStaging ) return __( 'Staging' );
+ return __( 'Production' );
+}
+
+function getEnvironmentIntent( site: SyncSite ) {
+ if ( site.isPressable && site.environmentType === 'development' ) return 'informational';
+ if ( site.isStaging || ( site.isPressable && site.environmentType === 'staging' ) )
+ return 'medium';
+ return 'stable';
+}
+
+function getSiteStatus( site: SyncSite, group: ConnectSiteGroup ): string {
+ if ( group === 'needs-transfer' ) {
+ return __( 'Enable hosting features on WordPress.com before connecting this site.' );
+ }
+ if ( group === 'needs-upgrade' ) {
+ return __( 'Upgrade this site to a supported plan before connecting it.' );
+ }
+ if ( site.syncSupport === 'missing-permissions' ) {
+ return __( "Your account doesn't have permission to manage this site." );
+ }
+ if ( site.syncSupport === 'deleted' ) return __( 'This site has been deleted.' );
+ return __( 'This site does not support pulling into Studio.' );
+}
+
+export function getSiteName( site: SyncSite ): string {
+ if ( site.name.trim() ) return site.name.trim();
+ try {
+ return new URL( site.url ).hostname;
+ } catch {
+ return __( 'WordPress site' );
+ }
+}
+
+function RemoteSiteCard( {
+ site,
+ group,
+ isSelected,
+ onSelect,
+}: ReturnType< typeof presentRemoteSites >[ number ] & {
+ isSelected: boolean;
+ onSelect: ( id: number ) => void;
+} ) {
+ const connector = useConnector();
+ const isAvailable = group === 'available';
+ const siteName = getSiteName( site );
+ const providerLabel = site.isPressable ? __( 'Pressable' ) : __( 'WP.com' );
+ const environmentLabel = getEnvironmentLabel( site );
+ const siteStatus = isAvailable ? '' : getSiteStatus( site, group );
+ const className = clsx(
+ styles.siteCard,
+ isSelected && styles.siteCardSelected,
+ ! isAvailable && styles.siteCardUnavailable
+ );
+
+ return (
+
+ isAvailable && onSelect( site.id ) }
+ >
+
+
+
+ { providerLabel }
+ { environmentLabel }
+
+
+
+ { siteName }
+ { site.url.replace( /^https?:\/\//, '' ) }
+ { siteStatus && { siteStatus } }
+
+
+ { group === 'needs-transfer' && (
+
+ void connector.openExternalUrl( `https://wordpress.com/hosting-features/${ site.id }` )
+ }
+ >
+ { __( 'Enable hosting features' ) }
+
+
+ ) }
+ { group === 'needs-upgrade' && (
+
+ void connector.openExternalUrl( `https://wordpress.com/plans/${ site.id }` )
+ }
+ >
+ { __( 'View plans' ) }
+
+
+ ) }
+
+ );
+}
+
+export type ConnectSitePickerProps = {
+ sites: SyncSite[] | undefined;
+ isLoading: boolean;
+ isFetching: boolean;
+ error: unknown;
+ onRefresh: () => void;
+ selectedId: number | null;
+ onSelect: ( id: number ) => void;
+ // Shown when the account has no sites this flow can use.
+ emptyTitle?: string;
+ emptyDescription?: string;
+};
+
+/**
+ * The list of WordPress.com and Pressable sites a Studio site can be wired to,
+ * with its search, its grouping into what can and can't be connected, and the
+ * states around loading them. Shared by onboarding, which uses it to bring a
+ * live site down into Studio, and by publishing, which uses it to send one up —
+ * the choice is the same either way, so it should look the same.
+ */
+export function ConnectSitePicker( {
+ sites,
+ isLoading,
+ isFetching,
+ error,
+ onRefresh,
+ selectedId,
+ onSelect,
+ emptyTitle = __( 'No sites found' ),
+ emptyDescription = __( 'This account has no WordPress.com or Pressable sites to show.' ),
+}: ConnectSitePickerProps ) {
+ const connector = useConnector();
+ const locale = useUserLocale();
+ const isOffline = useOffline();
+ const [ searchQuery, setSearchQuery ] = useState( '' );
+
+ const presentedSites = useMemo( () => presentRemoteSites( sites ?? [] ), [ sites ] );
+ const filteredSites = useMemo(
+ () => searchRemoteSites( presentedSites, searchQuery ),
+ [ presentedSites, searchQuery ]
+ );
+ const isSingleSite = presentedSites.length === 1 && searchQuery.trim() === '';
+ const isSingleAvailableSite = isSingleSite && presentedSites[ 0 ].group === 'available';
+
+ if ( isOffline ) {
+ return (
+
+
{ __( "You're offline" ) }
+
{ __( 'Reconnect to load your WordPress.com and Pressable sites.' ) }
+
+ );
+ }
+
+ if ( isLoading ) {
+ return (
+
+
+
{ __( 'Loading your sites…' ) }
+
+ );
+ }
+
+ if ( error ) {
+ return (
+
+
{ __( "We couldn't load your sites" ) }
+
{ __( 'Check your connection and try again.' ) }
+
+ { __( 'Retry' ) }
+
+
+ );
+ }
+
+ if ( presentedSites.length === 0 ) {
+ return (
+
+
{ emptyTitle }
+
{ emptyDescription }
+
void connector.openExternalUrl( createWpcomSiteUrl.toString() ) }
+ >
+ { __( 'Create a WordPress.com site' ) }
+
+
+
+ );
+ }
+
+ const sections = [
+ {
+ key: 'available',
+ title: __( 'Available to connect' ),
+ description: __( 'Select a site to create its local copy.' ),
+ sites: filteredSites.filter( ( entry ) => entry.group === 'available' ),
+ },
+ {
+ key: 'unavailable',
+ title: __( 'Unavailable' ),
+ description: __( 'These sites cannot currently be connected to Studio.' ),
+ sites: filteredSites.filter( ( entry ) => entry.group !== 'available' ),
+ },
+ ];
+
+ return (
+ <>
+
+ { ! isSingleSite && (
+
+
+ { __( 'Search sites' ) }
+ setSearchQuery( event.target.value ) }
+ />
+
+ ) }
+
+
+ { isFetching ? __( 'Refreshing…' ) : __( 'Refresh list' ) }
+
+ ·
+
+ void connector.openExternalUrl( getLocalizedLink( locale, 'docsSyncSupportedSites' ) )
+ }
+ >
+ { __( 'Supported sites' ) }
+
+
+
+
+
+ { filteredSites.length === 0 ? (
+
+
+ { sprintf(
+ // translators: %s is the site search query.
+ __( 'No sites match “%s”.' ),
+ searchQuery
+ ) }
+
+
+ ) : isSingleAvailableSite ? (
+
+ ) : (
+
+ { sections.map(
+ ( section ) =>
+ section.sites.length > 0 && (
+
+
+
{ section.title }
+
{ section.description }
+
+
+ { section.sites.map( ( entry ) => (
+
+ ) ) }
+
+
+ )
+ ) }
+
+ ) }
+ >
+ );
+}
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts b/apps/ui/src/components/connect-site-picker/site-presentation.test.ts
similarity index 100%
rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts
rename to apps/ui/src/components/connect-site-picker/site-presentation.test.ts
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts b/apps/ui/src/components/connect-site-picker/site-presentation.ts
similarity index 100%
rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts
rename to apps/ui/src/components/connect-site-picker/site-presentation.ts
diff --git a/apps/ui/src/components/connect-site-picker/style.module.css b/apps/ui/src/components/connect-site-picker/style.module.css
new file mode 100644
index 0000000000..39e8fbe5df
--- /dev/null
+++ b/apps/ui/src/components/connect-site-picker/style.module.css
@@ -0,0 +1,226 @@
+/* The site cards, search, and list states shared by onboarding and the
+ publish flow. */
+
+.state p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ max-width: 520px;
+ margin-inline: auto;
+ padding: 0 20px 32px;
+}
+
+.state h2 {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 500;
+}
+
+.siteControls {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 32px;
+}
+
+.search {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: min(100%, 520px);
+ padding: 0 12px;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 6px;
+ background: var(--wpds-color-bg-surface-neutral);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.search:focus-within {
+ border-color: var(--wpds-color-stroke-focus-brand);
+ box-shadow: 0 0 0 1px var(--wpds-color-stroke-focus-brand);
+}
+
+.search input {
+ width: 100%;
+ height: 40px;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral);
+ font: inherit;
+}
+
+.search input::placeholder {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.helperLinks {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.helperLinks button {
+ padding-inline: 2px;
+}
+
+.sections {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ text-align: start;
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.sectionHeader {
+ text-align: center;
+}
+
+.sectionHeader h2 {
+ margin: 0 0 4px;
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 500;
+}
+
+.sectionHeader p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+}
+
+.siteGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
+ gap: 20px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.singleSiteGrid {
+ grid-template-columns: minmax(0, 440px);
+ justify-content: center;
+}
+
+.siteCardWrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.siteCard {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ width: 100%;
+ padding: 6px;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral);
+ cursor: pointer;
+ text-align: start;
+}
+
+.siteCardUnavailable,
+.siteCardUnavailable:hover {
+ cursor: default;
+}
+
+.siteCardSelected .siteThumb {
+ box-shadow: 0 0 0 1px var(--wpds-color-stroke-interactive-brand);
+}
+
+.siteCard:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.siteThumb {
+ position: relative;
+ display: block;
+ width: 100%;
+ aspect-ratio: 3 / 2;
+ overflow: hidden;
+ border-radius: 8px;
+ background: var(--wpds-color-bg-surface-neutral-strong);
+ box-shadow: 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-surface-neutral);
+ transition: box-shadow 0.15s ease;
+}
+
+.siteThumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.siteCardUnavailable .siteThumb {
+ opacity: 0.65;
+}
+
+.siteText {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+ padding: 10px 8px 8px;
+}
+
+.siteName {
+ overflow: hidden;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.siteUrl {
+ overflow: hidden;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.siteStatus {
+ margin-top: 4px;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: 1.4;
+}
+
+.badges {
+ position: absolute;
+ inset-inline-end: 8px;
+ inset-block-end: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.siteAction {
+ align-self: center;
+ margin-top: 4px;
+}
+
+
+@media (max-width: 600px) {
+ .siteGrid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/apps/ui/src/components/site-toolbar/index.tsx b/apps/ui/src/components/site-toolbar/index.tsx
index 0fa1d81994..7d94af0bfe 100644
--- a/apps/ui/src/components/site-toolbar/index.tsx
+++ b/apps/ui/src/components/site-toolbar/index.tsx
@@ -1,7 +1,7 @@
import { useIsMutating } from '@tanstack/react-query';
import { __ } from '@wordpress/i18n';
import { external, Icon, moreVertical } from '@wordpress/icons';
-import { Button, Dialog, IconButton, Tooltip } from '@wordpress/ui';
+import { Button, IconButton, Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import * as Menu from '@/components/menu';
@@ -21,7 +21,7 @@ import {
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { getSiteDisplayUrl, getSiteUrl } from '@/lib/get-site-url';
import { DisconnectSiteDialog } from './disconnect-site-dialog';
-import { PublishPickerView } from './publish-picker-view';
+import { PublishSiteDialog } from './publish-site-dialog';
import { ShareDialog } from './share-dialog';
import styles from './style.module.css';
import { SyncDialog, type SyncDirection } from './sync-dialog';
@@ -294,11 +294,7 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo
{ /* Mounted only while open: it loads the account's sites on mount. */ }
{ publishOpen ? (
-
-
- setPublishOpen( false ) } />
-
-
+
) : null }
{ shareOpen ? : null }
diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.module.css b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css
deleted file mode 100644
index 29f93bc641..0000000000
--- a/apps/ui/src/components/site-toolbar/publish-picker-view.module.css
+++ /dev/null
@@ -1,94 +0,0 @@
-.picker {
- display: flex;
- flex-direction: column;
-}
-
-.header {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-sm);
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
- border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
-}
-
-.title {
- font-size: var(--wpds-typography-font-size-sm);
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.body {
- max-height: 240px;
- overflow-y: auto;
-}
-
-.status {
- padding: var(--wpds-dimension-padding-lg);
- color: var(--wpds-color-fg-content-neutral-weak);
- font-size: var(--wpds-typography-font-size-sm);
- text-align: center;
-}
-
-.list {
- list-style: none;
- margin: 0;
- padding: var(--wpds-dimension-padding-xs) 0;
-}
-
-.item {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 2px;
- width: 100%;
- background: transparent;
- border: none;
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg);
- cursor: pointer;
- font: inherit;
- color: inherit;
- text-align: left;
-}
-
-.item:hover,
-.item:focus-visible {
- background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
- outline: none;
-}
-
-.itemName {
- font-size: var(--wpds-typography-font-size-sm);
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.itemUrl {
- font-size: var(--wpds-typography-font-size-sm);
- color: var(--wpds-color-fg-content-neutral-weak);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
-}
-
-.create {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-sm);
- width: 100%;
- background: transparent;
- border: none;
- border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg);
- cursor: pointer;
- font: inherit;
- font-size: var(--wpds-typography-font-size-sm);
- color: var(--wpds-color-fg-interactive-brand);
- text-align: left;
-}
-
-.create:hover,
-.create:focus-visible {
- background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
- outline: none;
-}
diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx
deleted file mode 100644
index 4f8df43205..0000000000
--- a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useQueryClient } from '@tanstack/react-query';
-import { __ } from '@wordpress/i18n';
-import { chevronLeft, plus } from '@wordpress/icons';
-import { Icon, IconButton } from '@wordpress/ui';
-import { useConnector } from '@/data/core';
-import { useAuthUser } from '@/data/queries/use-auth-user';
-import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites';
-import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites';
-import styles from './publish-picker-view.module.css';
-import { stripProtocol } from './utils';
-import type { SiteDetails, SyncSite } from '@/data/core';
-
-type Props = {
- site: SiteDetails;
- // Fires after any action that ends the picker flow (site picked, checkout
- // link opened, or the back button pressed). The parent uses this to swap
- // back to the main dropdown view.
- onClose: () => void;
-};
-
-export function PublishPickerView( { site, onClose }: Props ) {
- const connector = useConnector();
- const queryClient = useQueryClient();
- const { data: authUser } = useAuthUser();
- const pickableSites = usePickableWpcomSites();
-
- const openExternal = ( url: string ) => {
- void connector.openExternalUrl( url );
- };
-
- const handlePickSite = async ( pickedSite: SyncSite ) => {
- try {
- await connector.connectWpcomSite( site.id, {
- ...pickedSite,
- localSiteId: site.id,
- syncSupport: 'already-connected',
- } );
- await queryClient.invalidateQueries( {
- queryKey: connectedWpcomSitesQueryKey( site.id ),
- } );
- onClose();
- } catch ( error ) {
- console.error( 'Failed to connect WordPress.com site:', error );
- }
- };
-
- const handleCreateNew = () => {
- const checkoutUrl = connector.getPublishCheckoutUrl( site );
- if ( checkoutUrl ) {
- // Desktop receives the new site via the wp-studio:// deep link; surfaces
- // that can't (the local web server) opt into a server-side watch instead.
- void connector.watchForPublishedSite?.( site.id );
- openExternal( checkoutUrl );
- }
- // The connect listener (deep link on desktop, sync-connect SSE on the local
- // server) handles the follow-up connection, so we just close the picker.
- onClose();
- };
-
- return (
-
-
-
- { __( 'Publish this site' ) }
-
- { authUser ? (
-
- { pickableSites.isLoading ? (
-
{ __( 'Loading sites…' ) }
- ) : pickableSites.data && pickableSites.data.length > 0 ? (
-
- { pickableSites.data.map( ( candidate ) => (
-
- void handlePickSite( candidate ) }
- >
- { candidate.name || candidate.url }
- { stripProtocol( candidate.url ) }
-
-
- ) ) }
-
- ) : (
-
- { __( 'No WordPress.com sites available to publish to.' ) }
-
- ) }
-
- ) : null }
-
-
- { __( 'Create a new WordPress.com site' ) }
-
-
- );
-}
diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css
new file mode 100644
index 0000000000..3201fae9ce
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css
@@ -0,0 +1,25 @@
+.intro {
+ margin: 0 0 var(--wpds-dimension-padding-lg);
+ font-size: var(--wpds-typography-font-size-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.error {
+ margin: 0 0 var(--wpds-dimension-padding-md);
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-error);
+ border-radius: var(--wpds-border-radius-md);
+ background: var(--wpds-color-bg-surface-error-weak);
+ color: var(--wpds-color-fg-content-error);
+ font-size: var(--wpds-typography-font-size-sm);
+}
+
+/* Sits opposite Cancel and Connect: making a new site is a way out of this
+ list, not a step in it. */
+.createButton {
+ margin-inline-end: auto;
+}
+
+.createButton svg {
+ fill: currentColor;
+}
diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx
new file mode 100644
index 0000000000..5638ecda79
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx
@@ -0,0 +1,143 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { __ } from '@wordpress/i18n';
+import { external, Icon } from '@wordpress/icons';
+import { Button, Dialog } from '@wordpress/ui';
+import { useState } from 'react';
+import { ConnectSitePicker } from '@/components/connect-site-picker';
+import { useConnector } from '@/data/core';
+import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites';
+import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites';
+import styles from './publish-site-dialog.module.css';
+import type { SiteDetails } from '@/data/core';
+
+type Props = {
+ site: SiteDetails;
+ open: boolean;
+ onOpenChange: ( open: boolean ) => void;
+};
+
+/**
+ * Choosing where a Studio site goes live. The same picker onboarding uses to
+ * bring a site down into Studio, pointed the other way — one list of the
+ * WordPress.com and Pressable sites this account can reach, with room to see
+ * them rather than a popover to squint at.
+ */
+export function PublishSiteDialog( { site, open, onOpenChange }: Props ) {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ const pickableSites = usePickableWpcomSites();
+ const [ selectedId, setSelectedId ] = useState< number | null >( null );
+ const [ isConnecting, setIsConnecting ] = useState( false );
+ const [ error, setError ] = useState( '' );
+
+ const selectedSite = pickableSites.data?.find( ( candidate ) => candidate.id === selectedId );
+
+ const close = ( next: boolean ) => {
+ if ( isConnecting ) {
+ return;
+ }
+ onOpenChange( next );
+ if ( ! next ) {
+ setSelectedId( null );
+ setError( '' );
+ }
+ };
+
+ const handleConnect = async () => {
+ if ( ! selectedSite || isConnecting ) {
+ return;
+ }
+ setIsConnecting( true );
+ setError( '' );
+ try {
+ await connector.connectWpcomSite( site.id, {
+ ...selectedSite,
+ localSiteId: site.id,
+ syncSupport: 'already-connected',
+ } );
+ await queryClient.invalidateQueries( { queryKey: connectedWpcomSitesQueryKey( site.id ) } );
+ close( false );
+ } catch ( caught ) {
+ setError(
+ caught instanceof Error
+ ? caught.message
+ : __( 'Failed to connect the site. Please try again.' )
+ );
+ } finally {
+ setIsConnecting( false );
+ }
+ };
+
+ const handleCreateNew = () => {
+ const checkoutUrl = connector.getPublishCheckoutUrl( site );
+ if ( checkoutUrl ) {
+ // Desktop receives the new site via the wp-studio:// deep link; surfaces
+ // that can't (the local web server) opt into a server-side watch instead.
+ void connector.watchForPublishedSite?.( site.id );
+ void connector.openExternalUrl( checkoutUrl );
+ }
+ // The connect listener (deep link on desktop, sync-connect SSE on the local
+ // server) handles the follow-up connection, so we just get out of the way.
+ close( false );
+ };
+
+ return (
+
+
+
+ { __( 'Publish this site' ) }
+
+
+
+ { __(
+ 'Choose the WordPress.com or Pressable site to publish to. Pushing sends this Studio site’s files and database there.'
+ ) }
+
+ { error ? (
+
+ { error }
+
+ ) : null }
+ void pickableSites.refetch() }
+ selectedId={ selectedId }
+ onSelect={ setSelectedId }
+ emptyTitle={ __( 'No sites available' ) }
+ emptyDescription={ __(
+ 'Every site on this account is already connected to a Studio site, or cannot be published to.'
+ ) }
+ />
+
+
+
+ { __( 'Create a new site' ) }
+
+
+
+ { __( 'Cancel' ) }
+
+ void handleConnect() }
+ >
+ { __( 'Connect site' ) }
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
index d3f3f9c165..df75f85274 100644
--- a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx
@@ -5,6 +5,11 @@ import { check, chevronLeft, external, search } from '@wordpress/icons';
import { Badge, Button, Icon } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ presentRemoteSites,
+ searchRemoteSites,
+ type ConnectSiteGroup,
+} from '@/components/connect-site-picker/site-presentation';
import { OnboardingFooter } from '@/components/onboarding-footer';
import { toast } from '@/data/app-messages';
import { useConnector } from '@/data/core';
@@ -18,7 +23,6 @@ import { getLocalizedLink } from '@/lib/docs-links';
import { onboardingLayoutRoute, useOnboardingProgress } from '../layout-onboarding';
import sharedStyles from '../layout-onboarding/style.module.css';
import { ConnectSiteLifecycleError, runConnectSiteLifecycle } from './connect-site';
-import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation';
import styles from './style.module.css';
import type { SyncSite } from '@/data/core';