diff --git a/apps/ui/src/components/site-dropdown/index.test.tsx b/apps/ui/src/components/site-dropdown/index.test.tsx
index 102f854038..204ed7a1dc 100644
--- a/apps/ui/src/components/site-dropdown/index.test.tsx
+++ b/apps/ui/src/components/site-dropdown/index.test.tsx
@@ -31,7 +31,9 @@ vi.mock( '@/data/sync-activity', () => ( { useSiteSyncActivity: () => null } ) )
vi.mock( '@/components/selective-sync/lib/get-ipc-api', () => ( {
registerSelectiveSyncConnector: vi.fn(),
} ) );
-vi.mock( './disconnect-site-dialog', () => ( { DisconnectSiteDialog: () => null } ) );
+vi.mock( '@/components/site-toolbar/disconnect-site-dialog', () => ( {
+ DisconnectSiteDialog: () => null,
+} ) );
vi.mock( '@/components/selective-sync/lib/convert-tree-to-sync-options', () => ( {
convertTreeToPullOptions: () => ( { optionsToSync: [ 'all' ], include_path_list: [] } ),
convertTreeToPushOptions: () => ( { optionsToSync: [ 'all' ] } ),
diff --git a/apps/ui/src/components/site-dropdown/index.tsx b/apps/ui/src/components/site-dropdown/index.tsx
index 3459363050..5f3cb69670 100644
--- a/apps/ui/src/components/site-dropdown/index.tsx
+++ b/apps/ui/src/components/site-dropdown/index.tsx
@@ -7,6 +7,13 @@ import {
import { registerSelectiveSyncConnector } from '@/components/selective-sync/lib/get-ipc-api';
import { SyncDialog } from '@/components/selective-sync/sync-dialog';
import '@/components/selective-sync/selective-sync.css';
+import { DisconnectSiteDialog } from '@/components/site-toolbar/disconnect-site-dialog';
+import {
+ deriveSiteStatus,
+ ensureProtocol,
+ pickLatestSnapshot,
+ pickLiveSite,
+} from '@/components/site-toolbar/utils';
import { useConnector } from '@/data/core';
import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
import { useIsSiteStarting, useIsSiteStopping, useSiteOperation } from '@/data/queries/use-sites';
@@ -14,13 +21,11 @@ import { useSnapshots } from '@/data/queries/use-snapshots';
import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site';
import { useSiteSyncActivity } from '@/data/sync-activity';
import { getSiteDisplayUrl } from '@/lib/get-site-url';
-import { DisconnectSiteDialog } from './disconnect-site-dialog';
import { DropdownTrigger } from './dropdown-trigger';
import { MainView } from './main-view';
import { PublishPickerView } from './publish-picker-view';
import styles from './style.module.css';
import { getSiteDropdownSecondary } from './trigger-secondary';
-import { deriveSiteStatus, ensureProtocol, pickLatestSnapshot, pickLiveSite } from './utils';
import type { TreeNode } from '@/components/selective-sync/tree-view';
import type { SiteDetails } from '@/data/core';
diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx
index b45df7c02e..39f65c6a42 100644
--- a/apps/ui/src/components/site-dropdown/main-view.tsx
+++ b/apps/ui/src/components/site-dropdown/main-view.tsx
@@ -9,6 +9,15 @@ import { Button, IconButton, Tooltip } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useMemo } from 'react';
import * as Menu from '@/components/menu';
+import {
+ deriveSiteStatus,
+ getSiteStatusName,
+ ensureProtocol,
+ getSnapshotHostname,
+ pickLatestSnapshot,
+ pickLiveSite,
+ stripProtocol,
+} from '@/components/site-toolbar/utils';
import { XdebugIcon } from '@/components/xdebug-icon';
import { useConnector } from '@/data/core';
import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
@@ -34,15 +43,6 @@ import { getSiteUrl } from '@/lib/get-site-url';
import styles from './main-view.module.css';
import { PopoverRow } from './popover-row';
import { getSyncActivityLabel } from './trigger-secondary';
-import {
- deriveSiteStatus,
- getSiteStatusName,
- ensureProtocol,
- getSnapshotHostname,
- pickLatestSnapshot,
- pickLiveSite,
- stripProtocol,
-} from './utils';
import type { SiteDetails } from '@/data/core';
import type { SyncActivity } from '@/data/sync-activity';
import type { ComponentProps } from 'react';
diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx b/apps/ui/src/components/site-dropdown/publish-picker-view.tsx
index 9bfe92bc9e..dc38d47c39 100644
--- a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx
+++ b/apps/ui/src/components/site-dropdown/publish-picker-view.tsx
@@ -3,13 +3,13 @@ import { __ } from '@wordpress/i18n';
import { chevronLeft, plus } from '@wordpress/icons';
import { Icon, IconButton } from '@wordpress/ui';
import { useEffect, useState } from 'react';
+import { stripProtocol } from '@/components/site-toolbar/utils';
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 { getWpcomLoadErrorDetail } from '@/lib/wpcom-load-error';
import styles from './publish-picker-view.module.css';
-import { stripProtocol } from './utils';
import type { SiteDetails, SyncSite } from '@/data/core';
type Props = {
diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx
index c417fcd223..84fdb040b5 100644
--- a/apps/ui/src/components/site-list/index.tsx
+++ b/apps/ui/src/components/site-list/index.tsx
@@ -28,7 +28,7 @@ import { DeleteSiteDialog } from '@/components/delete-site-dialog';
import * as Menu from '@/components/menu';
import { ReorderableList } from '@/components/reorderable-list';
import { SidebarButton } from '@/components/sidebar-button';
-import { deriveSiteStatus, getSiteStatusName } from '@/components/site-dropdown/utils';
+import { deriveSiteStatus, getSiteStatusName } from '@/components/site-toolbar/utils';
import { XdebugIcon } from '@/components/xdebug-icon';
import { useConnector } from '@/data/core';
import { useSiteAgentActivity, type SiteAgentActivity } from '@/data/queries/use-agent-run';
diff --git a/apps/ui/src/components/site-dropdown/disconnect-site-dialog.module.css b/apps/ui/src/components/site-toolbar/disconnect-site-dialog.module.css
similarity index 100%
rename from apps/ui/src/components/site-dropdown/disconnect-site-dialog.module.css
rename to apps/ui/src/components/site-toolbar/disconnect-site-dialog.module.css
diff --git a/apps/ui/src/components/site-dropdown/disconnect-site-dialog.tsx b/apps/ui/src/components/site-toolbar/disconnect-site-dialog.tsx
similarity index 100%
rename from apps/ui/src/components/site-dropdown/disconnect-site-dialog.tsx
rename to apps/ui/src/components/site-toolbar/disconnect-site-dialog.tsx
diff --git a/apps/ui/src/components/site-toolbar/index.tsx b/apps/ui/src/components/site-toolbar/index.tsx
new file mode 100644
index 0000000000..8f1aee26e5
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/index.tsx
@@ -0,0 +1,397 @@
+import { isSnapshotExpired } from '@studio/common/lib/snapshots';
+import { useIsMutating } from '@tanstack/react-query';
+import { __ } from '@wordpress/i18n';
+import { chevronDownSmall, moreVertical } from '@wordpress/icons';
+import { Button, Icon, IconButton } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { CopyButton } from '@/components/copy-button';
+import * as Menu from '@/components/menu';
+import {
+ convertTreeToPullOptions,
+ convertTreeToPushOptions,
+} from '@/components/selective-sync/lib/convert-tree-to-sync-options';
+import { registerSelectiveSyncConnector } from '@/components/selective-sync/lib/get-ipc-api';
+import { SyncDialog } from '@/components/selective-sync/sync-dialog';
+import { SiteIcon } from '@/components/site-icon';
+import { useConnector } from '@/data/core';
+import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
+import { useLogin } from '@/data/queries/use-auth-user';
+import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
+import {
+ PUBLISH_PREVIEW_MUTATION_KEY,
+ usePublishPreviewSite,
+} from '@/data/queries/use-preview-site';
+import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots';
+import {
+ PULL_FROM_LIVE_MUTATION_KEY,
+ PUSH_TO_LIVE_MUTATION_KEY,
+ usePullSiteFromLive,
+ usePushSiteToLive,
+} from '@/data/queries/use-sync-site';
+import { useSiteSyncActivity } from '@/data/sync-activity';
+import { getSiteDisplayUrl } from '@/lib/get-site-url';
+import { DisconnectSiteDialog } from './disconnect-site-dialog';
+import { PublishPickerView } from './publish-picker-view';
+import styles from './style.module.css';
+import {
+ ensureProtocol,
+ getSnapshotHostname,
+ pickLatestSnapshot,
+ pickLiveSite,
+ stripProtocol,
+} from './utils';
+import '@/components/selective-sync/selective-sync.css';
+import type { TreeNode } from '@/components/selective-sync/tree-view';
+import type { SiteDetails } from '@/data/core';
+
+interface SiteToolbarProps {
+ site: SiteDetails;
+ className?: string;
+ // Opens the Pull dialog once the connection loads. Set by the deep link
+ // onboarding follows after connecting a site, to nudge the first pull.
+ openPullOnLoad?: boolean;
+}
+
+// Counts in-flight operations that export or mutate this site's local files.
+// A mutation started from another surface must still disable this header so
+// preview publishing, push, and pull cannot overlap.
+function useIsSiteBusy( siteId: string ): boolean {
+ const forSite = ( mutation: { state: { variables?: unknown } } ) =>
+ ( mutation.state.variables as { siteId?: string } | undefined )?.siteId === siteId;
+ const push = useIsMutating( { mutationKey: PUSH_TO_LIVE_MUTATION_KEY, predicate: forSite } ) > 0;
+ const pull =
+ useIsMutating( { mutationKey: PULL_FROM_LIVE_MUTATION_KEY, predicate: forSite } ) > 0;
+ const preview =
+ useIsMutating( { mutationKey: PUBLISH_PREVIEW_MUTATION_KEY, predicate: forSite } ) > 0;
+ return push || pull || preview;
+}
+
+/**
+ * The site's permanent header: who you're working on on the left, its actions
+ * on the right. Replaces the old site dropdown, whose actions were hidden
+ * behind a trigger that read as a status indicator.
+ *
+ * A connected site gets a Sync menu (push/pull) and Share; an unconnected one
+ * gets Publish to connect a WordPress.com site. Signed-out users see Log in.
+ */
+export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteToolbarProps ) {
+ const connector = useConnector();
+ const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures();
+ const login = useLogin( { source: 'site_header' } );
+ const pushSiteToLive = usePushSiteToLive();
+ const pullSiteFromLive = usePullSiteFromLive();
+ const publishPreviewSite = usePublishPreviewSite();
+
+ const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null );
+ const [ publishOpen, setPublishOpen ] = useState( false );
+ const [ disconnectOpen, setDisconnectOpen ] = useState( false );
+ const [ shareMenuOpen, setShareMenuOpen ] = useState( false );
+ const [ publishedPreviewUrl, setPublishedPreviewUrl ] = useState< string | undefined >();
+
+ const isBusy = useIsSiteBusy( site.id );
+ const syncActivity = useSiteSyncActivity( site.id );
+ const isPreviewPending =
+ publishPreviewSite.isPending ||
+ ( syncActivity?.kind === 'pending' && syncActivity.direction === 'preview' );
+
+ const { data: connectedSites } = useConnectedWpcomSites( site.id );
+ const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
+ const { data: snapshots } = useSnapshots();
+ const { data: snapshotUsage } = useSnapshotUsage();
+ const previewSnapshot = useMemo(
+ () => pickLatestSnapshot( snapshots, site.id ),
+ [ snapshots, site.id ]
+ );
+ const currentPreview =
+ previewSnapshot && ! isSnapshotExpired( previewSnapshot ) ? previewSnapshot : undefined;
+ const previewUrl = currentPreview?.url ?? publishedPreviewUrl;
+ const previewCreationBlocked =
+ snapshotUsage?.siteCreationBlocked === true ||
+ ( snapshotUsage?.siteCount ?? 0 ) >= ( snapshotUsage?.siteLimit ?? Infinity );
+
+ // The ported selective-sync modules resolve their data calls through the
+ // active connector (see selective-sync/lib/get-ipc-api.ts).
+ useEffect( () => {
+ registerSelectiveSyncConnector( connector );
+ }, [ connector ] );
+
+ // Honour the onboarding deep link once the connection is known: open Pull so
+ // a freshly connected site can bring the live content down. Fires once.
+ const pullOpenedRef = useRef( false );
+ useEffect( () => {
+ if ( openPullOnLoad && liveSite && ! pullOpenedRef.current ) {
+ pullOpenedRef.current = true;
+ setSyncDialogType( 'pull' );
+ }
+ }, [ openPullOnLoad, liveSite ] );
+
+ const isSignedOut = agenticReason === 'signed-out';
+ const isOffline = agenticReason === 'offline';
+ const syncDisabled = ! agenticEnabled || isBusy;
+
+ const openExternal = ( url: string ) => {
+ void connector.openExternalUrl( url );
+ };
+
+ const handleDialogPush = ( tree: TreeNode[] ) => {
+ if ( ! liveSite ) {
+ return;
+ }
+ const options = convertTreeToPushOptions( tree );
+ pushSiteToLive.mutate(
+ { siteId: site.id, remoteSiteId: liveSite.id, options },
+ { onSuccess: () => openExternal( ensureProtocol( liveSite.url ) ) }
+ );
+ setSyncDialogType( null );
+ };
+
+ const handleDialogPull = ( tree: TreeNode[] ) => {
+ if ( ! liveSite ) {
+ return;
+ }
+ const { optionsToSync, include_path_list: includePathList } = convertTreeToPullOptions( tree );
+ pullSiteFromLive.mutate( {
+ siteId: site.id,
+ remoteSiteId: liveSite.id,
+ options: { optionsToSync, includePathList },
+ } );
+ setSyncDialogType( null );
+ };
+
+ const publishPreview = () => {
+ if ( syncDisabled ) {
+ return;
+ }
+ publishPreviewSite.mutate(
+ {
+ siteId: site.id,
+ // Pass the existing hostname so the CLI updates that preview instead of
+ // creating a new one (see getSnapshotHostname).
+ existingHostname: currentPreview
+ ? getSnapshotHostname( currentPreview )
+ : previewUrl
+ ? stripProtocol( previewUrl )
+ : undefined,
+ },
+ {
+ onSuccess: ( { url } ) => {
+ setPublishedPreviewUrl( url );
+ setShareMenuOpen( true );
+ },
+ }
+ );
+ };
+
+ return (
+
+
+
+
+ { site.name }
+ { getSiteDisplayUrl( site ) }
+
+
+
+
+ { ! isSignedOut ? (
+
+
+
+ { __( 'Share' ) }
+
+
+
+ }
+ />
+
+ { previewUrl ? (
+ <>
+
+
+
+ { stripProtocol( previewUrl ) }
+
+
+
+
+
+
+
openExternal( ensureProtocol( previewUrl ) ) }
+ >
+ { __( 'Open in browser' ) }
+
+
+ { __( 'Push' ) }
+
+
+ >
+ ) : (
+ <>
+
+ { __( 'Create a temporary share link that expires in 7 days.' ) }
+
+
+ { __( 'Create preview' ) }
+
+ >
+ ) }
+
+
+ ) : null }
+ { isSignedOut ? (
+
+ ) : liveSite ? (
+ <>
+
+
+
+ { __( 'Sync' ) }
+
+
+
+ }
+ />
+
+
+ { __(
+ 'Move files and database content between this local site and its connected live site.'
+ ) }
+
+
+
setSyncDialogType( 'push' ) }
+ >
+ { __( 'Push…' ) }
+
+ setSyncDialogType( 'pull' ) }
+ >
+ { __( 'Pull…' ) }
+
+
+
+
+
+
+ }
+ />
+
+ setDisconnectOpen( true ) }>
+ { __( 'Disconnect' ) }
+
+
+
+ >
+ ) : (
+
+
+
+ { __( 'Publish' ) }
+
+
+
+ }
+ />
+ { publishOpen ? (
+
+ setPublishOpen( false ) } />
+
+ ) : null }
+
+ ) }
+
+
+ { liveSite && syncDialogType ? (
+
setSyncDialogType( null ) }
+ />
+ ) : null }
+
+ { liveSite ? (
+
+ ) : 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
new file mode 100644
index 0000000000..5a609468a0
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css
@@ -0,0 +1,125 @@
+.status,
+.statusError {
+ margin: 0;
+ padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.35;
+}
+
+.status {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.statusError {
+ color: var(--wpds-color-fg-content-error);
+}
+
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-gap-xs);
+ margin: 0;
+ padding: var(--wpds-dimension-padding-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.4;
+}
+
+.emptyState strong {
+ color: var(--wpds-color-fg-content-neutral);
+ font-weight: 600;
+}
+
+.loadingStatus {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-sm);
+ padding: var(--wpds-dimension-padding-md);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ text-align: center;
+}
+
+.loadingLine {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-sm);
+}
+
+.spinner.spinner {
+ flex: 0 0 auto;
+ width: 16px;
+ height: 16px;
+ margin: 0;
+}
+
+.loadingHint {
+ line-height: 1.35;
+}
+
+.loadError {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-gap-xs);
+ padding: var(--wpds-dimension-padding-sm);
+ color: var(--wpds-color-fg-content-error);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.4;
+}
+
+.loadError strong {
+ font-weight: 600;
+}
+
+.siteList {
+ max-height: min(320px, calc(100vh - 160px));
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.sectionLabel {
+ margin: 0;
+ padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.siteItem {
+ min-width: 0;
+}
+
+.siteText {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ min-width: 0;
+}
+
+.siteName,
+.siteUrl {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.siteName {
+ color: var(--wpds-color-fg-content-neutral);
+ font-weight: 600;
+}
+
+.siteUrl {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+}
+
+.createItem {
+ justify-content: space-between;
+}
+
+.menuIcon {
+ fill: currentColor;
+}
diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx
new file mode 100644
index 0000000000..0a7cff218c
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx
@@ -0,0 +1,151 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { Spinner } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { external, Icon } from '@wordpress/icons';
+import { useEffect, useState } from 'react';
+import * as Menu from '@/components/menu';
+import { useConnector } from '@/data/core';
+import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites';
+import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites';
+import { getWpcomLoadErrorDetail } from '@/lib/wpcom-load-error';
+import styles from './publish-picker-view.module.css';
+import { stripProtocol } from './utils';
+import type { SiteDetails, SyncSite } from '@/data/core';
+
+type Props = {
+ site: SiteDetails;
+ onClose: () => void;
+};
+
+export function PublishPickerView( { site, onClose }: Props ) {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ const pickableSites = usePickableWpcomSites();
+ const sites = pickableSites.data;
+ const isLoading = sites === undefined && ( pickableSites.isLoading || pickableSites.isFetching );
+ const loadFailed = !! pickableSites.error && pickableSites.data === undefined;
+ const [ connectingId, setConnectingId ] = useState< number | null >( null );
+ const [ error, setError ] = useState( '' );
+ const [ slowLoading, setSlowLoading ] = useState( false );
+ const loadErrorDetail = getWpcomLoadErrorDetail( pickableSites.error );
+
+ useEffect( () => {
+ if ( ! isLoading ) {
+ setSlowLoading( false );
+ return;
+ }
+ const timer = window.setTimeout( () => setSlowLoading( true ), 6_000 );
+ return () => window.clearTimeout( timer );
+ }, [ isLoading ] );
+
+ const handlePickSite = async ( pickedSite: SyncSite ) => {
+ if ( connectingId !== null ) {
+ return;
+ }
+ setConnectingId( pickedSite.id );
+ setError( '' );
+ try {
+ // Picking an existing site links it directly, so it's connected without a
+ // sync-eligibility check.
+ await connector.connectWpcomSite( site.id, {
+ ...pickedSite,
+ localSiteId: site.id,
+ syncSupport: 'already-connected',
+ } );
+ await queryClient.invalidateQueries( {
+ queryKey: connectedWpcomSitesQueryKey( site.id ),
+ } );
+ onClose();
+ } catch ( caught ) {
+ setError(
+ caught instanceof Error
+ ? caught.message
+ : __( 'Failed to connect the site. Please try again.' )
+ );
+ } finally {
+ setConnectingId( null );
+ }
+ };
+
+ const handleCreateNew = () => {
+ const checkoutUrl = connector.getPublishCheckoutUrl( site );
+ if ( checkoutUrl ) {
+ void connector.watchForPublishedSite?.( site.id );
+ void connector.openExternalUrl( checkoutUrl );
+ }
+ onClose();
+ };
+
+ return (
+ <>
+ { error ? (
+
+ { error }
+
+ ) : null }
+ { isLoading ? (
+
+
+
+ { __( 'Loading WordPress.com sites…' ) }
+
+ { slowLoading ? (
+
+ { __( 'Large accounts can take a little longer.' ) }
+
+ ) : null }
+
+ ) : loadFailed ? (
+ <>
+
+ { __( 'Couldn’t load your WordPress.com sites.' ) }
+ { loadErrorDetail }
+
+ void pickableSites.refetch() }>
+ { __( 'Retry' ) }
+
+ >
+ ) : sites && sites.length > 0 ? (
+ <>
+ { __( 'Available sites' ) }
+
+
+ { sites.map( ( candidate ) => (
+ void handlePickSite( candidate ) }
+ >
+
+
+ { connectingId === candidate.id
+ ? __( 'Connecting…' )
+ : candidate.name || candidate.url }
+
+ { stripProtocol( candidate.url ) }
+
+
+ ) ) }
+
+
+ >
+ ) : (
+
+ { __( 'Publish to WordPress.com' ) }
+ { __( 'Create and connect a site for future pushes and pulls.' ) }
+
+ ) }
+
+
+ { __( 'Create a site…' ) }
+
+
+ >
+ );
+}
diff --git a/apps/ui/src/components/site-toolbar/style.module.css b/apps/ui/src/components/site-toolbar/style.module.css
new file mode 100644
index 0000000000..b41cfbcf34
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/style.module.css
@@ -0,0 +1,254 @@
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-gap-md);
+ padding-inline-start: 7px;
+ /* Fills whatever header row hosts it, so the action stays pinned to the
+ panel's right edge, and takes the row's full height so the actions have a
+ top to align to. */
+ flex: 1;
+ align-self: stretch;
+ min-width: 0;
+ /* The window drags by the toolbar's empty space; its controls opt back out
+ below so clicks reach them. */
+ -webkit-app-region: drag;
+}
+
+.identity {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-md);
+ min-width: 0;
+}
+
+.siteIcon {
+ flex: 0 0 auto;
+ inline-size: 32px;
+ block-size: 32px;
+ border-radius: var(--wpds-border-radius-sm);
+}
+
+.identityText {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.siteName {
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 600;
+ line-height: 1.25;
+ color: var(--wpds-color-fg-content-neutral);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.siteUrlStatic {
+ min-width: 0;
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.25;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-lg);
+ flex: 0 0 auto;
+ /* Pinned to the top of the header rather than centred against the site's
+ two-line identity block so it lines up with the preview toolbar. */
+ align-self: flex-start;
+ padding-block-start: 3px;
+ -webkit-app-region: no-drag;
+}
+
+.syncMenu {
+ width: 240px;
+}
+
+.menuIntro {
+ margin: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm)
+ var(--wpds-dimension-padding-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.4;
+}
+
+.primaryAction {
+ min-width: 72px;
+}
+
+/* Pairs a button's label with a menu-indicator chevron, close enough to read
+ as one unit rather than a separate icon. Flips 180° when the menu it opens
+ is open — Base UI stamps data-popup-open on the trigger itself, so this
+ needs no extra state. */
+.actionLabelWithChevron {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.menuChevron {
+ fill: currentColor;
+ transition: transform 150ms ease;
+}
+
+[data-popup-open] .menuChevron {
+ transform: rotate(180deg);
+}
+
+.publishMenu {
+ min-width: 0;
+ width: 240px;
+}
+
+/* --- Primary action ------------------------------------------------------ */
+
+.action {
+ position: relative;
+ /* A stable floor so the label can change without the toolbar reflowing
+ around it. */
+ min-width: 64px;
+ justify-content: center;
+ /* Keeps the progress fill inside the button's rounded edge. */
+ overflow: hidden;
+ transition: min-width 200ms ease;
+}
+
+/* Fills from the leading edge as the sync advances. Drawn from the button's
+ own foreground so it works on the solid and the outline button alike. */
+.actionProgress {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: 0;
+ inline-size: var(--action-progress, 0%);
+ background: color-mix(in srgb, currentColor 22%, transparent);
+ transition: inline-size 400ms ease;
+ pointer-events: none;
+}
+
+.actionLabel {
+ display: inline-flex;
+ align-items: center;
+ animation: actionLabelIn 200ms ease both;
+}
+
+@keyframes actionLabelIn {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+/* --- Details menu -------------------------------------------------------- */
+
+.menu {
+ min-width: 240px;
+ max-width: 320px;
+}
+
+.previewUrlRow {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+}
+
+.previewUrl {
+ flex: 1;
+ min-width: 0;
+ overflow-wrap: anywhere;
+ font-size: var(--wpds-typography-font-size-sm);
+}
+
+.shareActions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+}
+
+.shareAction {
+ flex: 1;
+ justify-content: center;
+}
+
+.shareCopy {
+ display: flex;
+ flex: 0 0 auto;
+}
+
+.previewDescription {
+ margin: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm) 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.35;
+}
+
+.shareMenu {
+ width: max-content;
+ min-width: 220px;
+ max-width: 420px;
+}
+
+.menuDetail {
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: 1.5;
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.menuDetailError {
+ color: var(--wpds-color-fg-content-error);
+}
+
+/* Names the list of connections a push or pull could run against. */
+.menuGroupLabel {
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md)
+ var(--wpds-dimension-padding-xs);
+ font-size: var(--wpds-typography-font-size-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.menuItemStack {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.menuItemSub {
+ font-size: var(--wpds-typography-font-size-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.menuItemMeta {
+ margin-inline-start: auto;
+ padding-inline-start: var(--wpds-dimension-padding-md);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .action,
+ .actionProgress,
+ .menuChevron {
+ transition: none;
+ }
+
+ .actionLabel {
+ animation: none;
+ }
+}
diff --git a/apps/ui/src/components/site-dropdown/utils.test.ts b/apps/ui/src/components/site-toolbar/utils.test.ts
similarity index 100%
rename from apps/ui/src/components/site-dropdown/utils.test.ts
rename to apps/ui/src/components/site-toolbar/utils.test.ts
diff --git a/apps/ui/src/components/site-dropdown/utils.ts b/apps/ui/src/components/site-toolbar/utils.ts
similarity index 95%
rename from apps/ui/src/components/site-dropdown/utils.ts
rename to apps/ui/src/components/site-toolbar/utils.ts
index ad0bc046d9..e4ec6bc837 100644
--- a/apps/ui/src/components/site-dropdown/utils.ts
+++ b/apps/ui/src/components/site-toolbar/utils.ts
@@ -2,9 +2,10 @@ import { type SiteOperationKind } from '@studio/common/lib/site-operation';
import { getSiteOperationLabel } from '@studio/common/lib/site-operation-labels';
import { __, sprintf } from '@wordpress/i18n';
import { getSiteDisplayUrl } from '@/lib/get-site-url';
-import type { SiteStatus } from './dropdown-trigger';
import type { SiteDetails, Snapshot, SyncSite } from '@/data/core';
+export type SiteRunStatus = 'running' | 'stopped' | 'transitioning';
+
export function stripProtocol( url: string ): string {
return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' );
}
@@ -84,7 +85,7 @@ function getStatus(
isStarting: boolean,
isStopping: boolean,
operation: SiteOperationKind | null
-): SiteStatus {
+): SiteRunStatus {
if ( operation || isStarting || isStopping ) {
return 'transitioning';
}
@@ -93,7 +94,7 @@ function getStatus(
// Sentence form, read out by the status dot's aria-label.
function getStatusLabel(
- status: SiteStatus,
+ status: SiteRunStatus,
isStopping: boolean,
operation: SiteOperationKind | null
): string {
@@ -112,7 +113,7 @@ function getStatusLabel(
// The local-site row's second line: what's happening, or where the site lives.
function getLocalSublabel(
site: SiteDetails,
- status: SiteStatus,
+ status: SiteRunStatus,
isStopping: boolean,
operation: SiteOperationKind | null
): string {
@@ -136,7 +137,7 @@ export function deriveSiteStatus(
// replace the two flags above. Passed in rather than derived here because
// it's react-query state and this stays a pure function.
operation: SiteOperationKind | null
-): { status: SiteStatus; statusLabel: string; localSublabel: string } {
+): { status: SiteRunStatus; statusLabel: string; localSublabel: string } {
const status = getStatus( site, isStarting, isStopping, operation );
return {
diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts
index 523388efa0..cd81e78948 100644
--- a/apps/ui/src/data/queries/use-preview-site.ts
+++ b/apps/ui/src/data/queries/use-preview-site.ts
@@ -10,13 +10,16 @@ type PublishPreviewVariables = {
existingHostname?: string;
};
+export const PUBLISH_PREVIEW_MUTATION_KEY = [ 'publishPreviewSite' ] as const;
+
// Creates or refreshes the WordPress.com-hosted preview snapshot for a
-// local site. Reports lifecycle into the shared sync-activity store so the
-// site-dropdown indicator can render the pending / success / error states.
+// local site. Reports lifecycle into the shared sync-activity store so
+// sync-activity consumers can render the pending / success / error states.
export function usePublishPreviewSite() {
const connector = useConnector();
const queryClient = useQueryClient();
return useMutation( {
+ mutationKey: PUBLISH_PREVIEW_MUTATION_KEY,
mutationFn: ( { siteId, existingHostname }: PublishPreviewVariables ) =>
connector.publishPreviewSite( siteId, existingHostname ),
onMutate: ( { siteId } ) => {
diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx
index dc66e1bc68..1ddc11288d 100644
--- a/apps/ui/src/ui-classic/components/session-view/index.tsx
+++ b/apps/ui/src/ui-classic/components/session-view/index.tsx
@@ -18,9 +18,9 @@ import {
} from 'react';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
import { ProgressiveBlur } from '@/components/progressive-blur';
-import { SiteDropdown } from '@/components/site-dropdown';
import { SiteIcon } from '@/components/site-icon';
import { type Annotation } from '@/components/site-preview/types';
+import { SiteToolbar } from '@/components/site-toolbar';
import { useAgentRun } from '@/data/queries/use-agent-run';
import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota';
import {
@@ -83,12 +83,7 @@ function SessionHeader( { summary }: SessionHeaderProps ) {
) }
>
{ site ? (
-
+
) : (
<>
@@ -97,9 +92,9 @@ function SessionHeader( { summary }: SessionHeaderProps ) {
{ effectiveEnvironment === 'live' ? __( 'Live' ) : __( 'Local' ) }
+
>
) }
-
);
}
diff --git a/apps/ui/src/ui-classic/components/session-view/style.module.css b/apps/ui/src/ui-classic/components/session-view/style.module.css
index 4e1b1a0a5e..5eeacef150 100644
--- a/apps/ui/src/ui-classic/components/session-view/style.module.css
+++ b/apps/ui/src/ui-classic/components/session-view/style.module.css
@@ -42,7 +42,7 @@
align-items: center;
gap: var(--wpds-dimension-padding-sm);
padding-block: var(--wpds-dimension-padding-sm);
- padding-inline: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-2xl);
+ padding-inline: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
min-height: 46px;
font-size: var(--wpds-typography-font-size-sm);
color: var(--wpds-color-fg-content-neutral);
@@ -50,11 +50,7 @@
}
.headerSidebarCollapsed {
- padding-top: calc(
- var(--wpds-dimension-padding-xl) + var(--wpds-dimension-padding-2xl) +
- var(--wpds-dimension-padding-lg)
- );
- padding-inline-start: var(--wpds-dimension-padding-xl);
+ padding-inline-start: 94px;
}
.headerSite {