diff --git a/apps/ui/src/components/site-overview-view/cards.module.css b/apps/ui/src/components/site-overview-view/cards.module.css
index 0852d6133a..ba012b05e5 100644
--- a/apps/ui/src/components/site-overview-view/cards.module.css
+++ b/apps/ui/src/components/site-overview-view/cards.module.css
@@ -16,6 +16,134 @@
min-width: 0;
}
+.sectionDivider {
+ height: var(--wpds-border-width-xs);
+ background: var(--wpds-color-stroke-surface-neutral);
+}
+
+.cardHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-sm);
+ min-height: 24px;
+}
+
+.cardTitle {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ font-weight: var(--wpds-typography-font-weight-medium);
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.empty {
+ margin: 0;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.rowList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-md);
+ min-width: 0;
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-sm);
+ min-width: 0;
+}
+
+.rowText {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.rowMeta {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-size: var(--wpds-typography-font-size-xs);
+ line-height: var(--wpds-typography-line-height-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.rowActions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-xs);
+ flex: 0 0 auto;
+}
+
+.rowDivider {
+ height: var(--wpds-border-width-xs);
+ background: var(--wpds-color-stroke-surface-neutral-weak);
+}
+
+.rowLink {
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ text-align: start;
+ font-family: inherit;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ color: var(--wpds-color-fg-content-neutral);
+ cursor: var(--wpds-cursor-control);
+}
+
+.rowLink:hover,
+.rowLink:focus-visible {
+ color: var(--wpds-color-fg-interactive-brand);
+ text-decoration: underline;
+}
+
+.stale {
+ color: var(--wpds-color-fg-content-warning);
+}
+
+.itemIcon {
+ display: grid;
+ place-items: center;
+ flex: 0 0 18px;
+ width: 18px;
+ height: 18px;
+}
+
+.itemIcon svg {
+ fill: currentColor;
+}
+
+.connectionSkeleton {
+ height: 10px;
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-surface-neutral-weak);
+ animation: connection-loading 1.4s ease-in-out infinite alternate;
+}
+
+@keyframes connection-loading {
+ to {
+ opacity: 0.55;
+ }
+}
+
+.pickerPopup {
+ width: 320px;
+ padding: 0;
+}
+
.themeSummary {
display: flex;
align-items: center;
@@ -264,7 +392,8 @@
@media (prefers-reduced-motion: reduce) {
.storageSkeleton,
- .storageLegend {
+ .storageLegend,
+ .connectionSkeleton {
animation: none;
transition: none;
}
diff --git a/apps/ui/src/components/site-overview-view/connections-section.tsx b/apps/ui/src/components/site-overview-view/connections-section.tsx
new file mode 100644
index 0000000000..dac35b6966
--- /dev/null
+++ b/apps/ui/src/components/site-overview-view/connections-section.tsx
@@ -0,0 +1,215 @@
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ arrowDown,
+ arrowUp,
+ copy,
+ external,
+ Icon,
+ moreVertical,
+ plus,
+ reusableBlock,
+} from '@wordpress/icons';
+import { Button, IconButton } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { Fragment, useState } from 'react';
+import * as Menu from '@/components/menu';
+import { PublishPickerView } from '@/components/site-dropdown/publish-picker-view';
+import { ensureProtocol, stripProtocol } from '@/components/site-dropdown/utils';
+import { useConnector } from '@/data/core';
+import { useAuthUser, useLogin } from '@/data/queries/use-auth-user';
+import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
+import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site';
+import { useIsSiteSyncing } from '@/hooks/use-is-site-syncing';
+import { useOffline } from '@/hooks/use-offline';
+import { formatRelativeTime } from '@/lib/format-relative-time';
+import styles from './cards.module.css';
+import { CardEmptyState, CardRows, CardSection, RowDivider } from './overview-card';
+import { RowLink } from './row-link';
+import type { SiteDetails, SyncSite } from '@/data/core';
+
+const STALE_SYNC_MS = 14 * 24 * 60 * 60 * 1000;
+
+export function ConnectionsSection( { site, busy }: { site: SiteDetails; busy: boolean } ) {
+ const { data: authUser } = useAuthUser();
+ const login = useLogin();
+ const isOffline = useOffline();
+ const [ pickerOpen, setPickerOpen ] = useState( false );
+ const { data: connections, isLoading } = useConnectedWpcomSites( site.id );
+
+ const connectAction = authUser ? (
+
+
+ }
+ />
+
+ setPickerOpen( false ) } />
+
+
+ ) : null;
+
+ return (
+
+ { ! authUser ? (
+ <>
+
+ { __( 'Sign in to connect this site to WordPress.com and sync it.' ) }
+
+
+
+
+ >
+ ) : isLoading && ! connections ? (
+
+ ) : ! connections?.length ? (
+
+ { __( 'Not connected to a live site yet. Connect one to pull or push changes.' ) }
+
+ ) : (
+
+ { connections.map( ( connection, index ) => (
+
+ { index > 0 && }
+
+
+ ) ) }
+
+ ) }
+
+ );
+}
+
+function ConnectionRow( {
+ site,
+ connection,
+ busy,
+}: {
+ site: SiteDetails;
+ connection: SyncSite;
+ busy: boolean;
+} ) {
+ const connector = useConnector();
+ const isOffline = useOffline();
+ const pushSiteToLive = usePushSiteToLive();
+ const pullSiteFromLive = usePullSiteFromLive();
+ const { push: isPushing, pull: isPulling } = useIsSiteSyncing( site.id );
+ const syncing = isPushing || isPulling;
+ const disabled = syncing || busy || isOffline;
+ const sync = describeLastSync( connection );
+ const url = ensureProtocol( connection.url );
+
+ return (
+
+
+
+ { sync.label }
+
+
+
+
+ }
+ />
+
+
+ pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: connection.id } )
+ }
+ >
+
+
+
+ { __( 'Pull from live' ) }
+
+
+ pushSiteToLive.mutate( { siteId: site.id, remoteSiteId: connection.id } )
+ }
+ >
+
+
+
+ { __( 'Push to live' ) }
+
+
+
+
+
+ }
+ />
+
+ void connector.openExternalUrl( url ) }>
+
+
+
+ { __( 'Open live site' ) }
+
+ void connector.copyText( url ) }>
+
+
+
+ { __( 'Copy URL' ) }
+
+
+
+
+
+ );
+}
+
+export function describeLastSync( connection: SyncSite ): { label: string; stale: boolean } {
+ const pulled = connection.lastPullTimestamp ? Date.parse( connection.lastPullTimestamp ) : NaN;
+ const pushed = connection.lastPushTimestamp ? Date.parse( connection.lastPushTimestamp ) : NaN;
+ const hasPulled = ! Number.isNaN( pulled );
+ const hasPushed = ! Number.isNaN( pushed );
+
+ if ( ! hasPulled && ! hasPushed ) {
+ return { label: __( 'Never synced' ), stale: true };
+ }
+
+ const pulledLast = hasPulled && ( ! hasPushed || pulled >= pushed );
+ const timestamp = ( pulledLast ? connection.lastPullTimestamp : connection.lastPushTimestamp )!;
+ const relative = formatRelativeTime( timestamp );
+ return {
+ label: pulledLast
+ ? sprintf( __( 'Pulled %s ago' ), relative )
+ : sprintf( __( 'Pushed %s ago' ), relative ),
+ stale: Date.now() - Math.max( hasPulled ? pulled : 0, hasPushed ? pushed : 0 ) > STALE_SYNC_MS,
+ };
+}
diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx
index f5f8ec6351..cac9618843 100644
--- a/apps/ui/src/components/site-overview-view/index.test.tsx
+++ b/apps/ui/src/components/site-overview-view/index.test.tsx
@@ -3,7 +3,8 @@ import { Tooltip } from '@wordpress/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConnector } from '@/data/core';
import { useAgenticFeatures } from '@/data/queries/use-agentic-features';
-import { useLogin } from '@/data/queries/use-auth-user';
+import { useAuthUser, useLogin } from '@/data/queries/use-auth-user';
+import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers';
import { useSiteStorageUsage } from '@/data/queries/use-site-storage-usage';
import { useSiteThumbnail } from '@/data/queries/use-site-thumbnail';
@@ -18,8 +19,10 @@ import {
useUpdateSite,
useXdebugEnabledSite,
} from '@/data/queries/use-sites';
+import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site';
import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { useWordPressVersions, useWpVersion } from '@/data/queries/use-wordpress-versions';
+import { useIsSiteSyncing } from '@/hooks/use-is-site-syncing';
import { useOffline } from '@/hooks/use-offline';
import styles from './style.module.css';
import { SiteOverviewView } from './index';
@@ -27,6 +30,7 @@ import type {
ConnectorCapabilities,
SiteDetails,
SupportedEditor,
+ SyncSite,
UserPreferences,
} from '@/data/core';
@@ -41,6 +45,18 @@ const WP_VERSIONS = [
{ label: '6.7.2', value: '6.7.2', isBeta: false, isDevelopment: false },
];
+const CONNECTED_SITE: SyncSite = {
+ id: 42,
+ localSiteId: 'site-1',
+ name: 'Demo Live',
+ url: 'demo.example.com',
+ isStaging: false,
+ isPressable: false,
+ syncSupport: 'syncable',
+ lastPullTimestamp: null,
+ lastPushTimestamp: null,
+};
+
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
@@ -68,6 +84,10 @@ vi.mock( '@/components/site-dropdown', () => ( {
},
} ) );
+vi.mock( '@/components/site-dropdown/publish-picker-view', () => ( {
+ PublishPickerView: () => Connection picker
,
+} ) );
+
vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );
@@ -77,9 +97,14 @@ vi.mock( '@/data/queries/use-agentic-features', () => ( {
} ) );
vi.mock( '@/data/queries/use-auth-user', () => ( {
+ useAuthUser: vi.fn(),
useLogin: vi.fn(),
} ) );
+vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( {
+ useConnectedWpcomSites: vi.fn(),
+} ) );
+
vi.mock( '@/data/queries/use-create-site-helpers', () => ( {
useExistingCustomDomains: vi.fn(),
} ) );
@@ -108,6 +133,11 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( {
useUserPreferences: vi.fn(),
} ) );
+vi.mock( '@/data/queries/use-sync-site', () => ( {
+ usePullSiteFromLive: vi.fn(),
+ usePushSiteToLive: vi.fn(),
+} ) );
+
vi.mock( '@/data/queries/use-wordpress-versions', () => ( {
useWordPressVersions: vi.fn(),
useWpVersion: vi.fn(),
@@ -121,12 +151,18 @@ vi.mock( '@/hooks/use-sidebar-collapsed', () => ( {
useSidebarCollapsed: useSidebarCollapsedMock,
} ) );
+vi.mock( '@/hooks/use-is-site-syncing', () => ( {
+ useIsSiteSyncing: vi.fn(),
+} ) );
+
vi.mock( '@/hooks/use-traffic-light-space', () => ( {
useTrafficLightSpace: useTrafficLightSpaceMock,
} ) );
const useConnectorMock = vi.mocked( useConnector, { partial: true } );
const useAgenticFeaturesMock = vi.mocked( useAgenticFeatures );
+const useAuthUserMock = vi.mocked( useAuthUser, { partial: true } );
+const useConnectedWpcomSitesMock = vi.mocked( useConnectedWpcomSites, { partial: true } );
const useLoginMock = vi.mocked( useLogin, { partial: true } );
const useExistingCustomDomainsMock = vi.mocked( useExistingCustomDomains, { partial: true } );
const useCopySiteMock = vi.mocked( useCopySite, { partial: true } );
@@ -140,10 +176,13 @@ const useSitesMock = vi.mocked( useSites, { partial: true } );
const useStartSiteMock = vi.mocked( useStartSite, { partial: true } );
const useUpdateSiteMock = vi.mocked( useUpdateSite, { partial: true } );
const useOfflineMock = vi.mocked( useOffline );
+const usePullSiteFromLiveMock = vi.mocked( usePullSiteFromLive, { partial: true } );
+const usePushSiteToLiveMock = vi.mocked( usePushSiteToLive, { partial: true } );
const useUserPreferencesMock = vi.mocked( useUserPreferences, { partial: true } );
const useWordPressVersionsMock = vi.mocked( useWordPressVersions, { partial: true } );
const useWpVersionMock = vi.mocked( useWpVersion, { partial: true } );
const useXdebugEnabledSiteMock = vi.mocked( useXdebugEnabledSite, { partial: true } );
+const useIsSiteSyncingMock = vi.mocked( useIsSiteSyncing );
describe( 'SiteOverviewView', () => {
const openSiteUrl = vi.fn().mockResolvedValue( undefined );
@@ -154,6 +193,10 @@ describe( 'SiteOverviewView', () => {
const copySite = vi.fn();
const exportFullSite = vi.fn();
const exportDatabase = vi.fn();
+ const openExternalUrl = vi.fn().mockResolvedValue( undefined );
+ const copyText = vi.fn().mockResolvedValue( undefined );
+ const pullSiteFromLive = vi.fn();
+ const pushSiteToLive = vi.fn();
const onTabChange = vi.fn();
const connectorStub = ( openInOS = true ) => ( {
@@ -161,6 +204,8 @@ describe( 'SiteOverviewView', () => {
openSiteFolder,
openSiteInEditor,
openSiteInTerminal,
+ openExternalUrl,
+ copyText,
capabilities: { openInOS } as ConnectorCapabilities,
} );
@@ -194,7 +239,14 @@ describe( 'SiteOverviewView', () => {
reason: null,
isReady: true,
} );
+ useAuthUserMock.mockReturnValue( {
+ data: { id: 1, email: 'person@example.com', displayName: 'Person' },
+ } );
useLoginMock.mockReturnValue( { isPending: false, mutate: vi.fn() } );
+ useConnectedWpcomSitesMock.mockReturnValue( { data: [], isLoading: false } );
+ usePullSiteFromLiveMock.mockReturnValue( { mutate: pullSiteFromLive } );
+ usePushSiteToLiveMock.mockReturnValue( { mutate: pushSiteToLive } );
+ useIsSiteSyncingMock.mockReturnValue( { push: false, pull: false } );
useExistingCustomDomainsMock.mockReturnValue( [] );
useSitesMock.mockReturnValue( {
data: [ createSite( { running: true } ) ],
@@ -312,6 +364,65 @@ describe( 'SiteOverviewView', () => {
expect( screen.getByText( 'Measuring…' ) ).toBeVisible();
} );
+ it( 'offers a complete empty state for connecting a live site', () => {
+ renderView();
+
+ expect( screen.getByRole( 'heading', { name: 'Connections' } ) ).toBeVisible();
+ expect(
+ screen.getByText( 'Not connected to a live site yet. Connect one to pull or push changes.' )
+ ).toBeVisible();
+ expect( screen.getByRole( 'button', { name: 'Connect a WordPress.com site' } ) ).toBeVisible();
+ } );
+
+ it( 'offers login from Connections when signed out', () => {
+ const login = vi.fn();
+ useAuthUserMock.mockReturnValue( { data: null } );
+ useLoginMock.mockReturnValue( { isPending: false, mutate: login } );
+
+ renderView();
+
+ expect(
+ screen.getByText( 'Sign in to connect this site to WordPress.com and sync it.' )
+ ).toBeVisible();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Log in with WordPress.com' } ) );
+ expect( login ).toHaveBeenCalled();
+ } );
+
+ it( 'shows a connected site and exposes pull and push actions', async () => {
+ useConnectedWpcomSitesMock.mockReturnValue( {
+ data: [ CONNECTED_SITE ],
+ isLoading: false,
+ } );
+
+ renderView();
+
+ expect( screen.getByText( 'demo.example.com' ) ).toBeVisible();
+ expect( screen.getByText( 'Never synced' ) ).toBeVisible();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Sync' } ) );
+ fireEvent.click( await screen.findByText( 'Pull from live' ) );
+ expect( pullSiteFromLive ).toHaveBeenCalledWith( { siteId: 'site-1', remoteSiteId: 42 } );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Sync' } ) );
+ fireEvent.click( await screen.findByText( 'Push to live' ) );
+ expect( pushSiteToLive ).toHaveBeenCalledWith( { siteId: 'site-1', remoteSiteId: 42 } );
+ } );
+
+ it( 'reflects sync work started from another surface', () => {
+ useConnectedWpcomSitesMock.mockReturnValue( {
+ data: [ CONNECTED_SITE ],
+ isLoading: false,
+ } );
+ useIsSiteSyncingMock.mockReturnValue( { push: true, pull: false } );
+
+ renderView();
+
+ expect( screen.getByRole( 'button', { name: 'Pushing…' } ) ).toHaveAttribute(
+ 'aria-disabled',
+ 'true'
+ );
+ } );
+
it( 'keeps the browser action available without a cached thumbnail', () => {
useSiteThumbnailMock.mockReturnValue( { data: null } );
diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx
index 27b237d273..1b02bcc906 100644
--- a/apps/ui/src/components/site-overview-view/index.tsx
+++ b/apps/ui/src/components/site-overview-view/index.tsx
@@ -34,7 +34,8 @@ import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { useSiteManagementActions } from '@/hooks/use-site-management-actions';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { AboutSection } from './about-section';
-import { OverviewCard } from './overview-card';
+import { ConnectionsSection } from './connections-section';
+import { CardSectionDivider, OverviewCard } from './overview-card';
import styles from './style.module.css';
import type { SiteSettingsTabId } from '@/components/site-settings-view';
import type { SiteDetails } from '@/data/core';
@@ -269,6 +270,8 @@ function SiteOverviewBody( {
{ __( 'About' ) }
+
+
diff --git a/apps/ui/src/components/site-overview-view/overview-card.tsx b/apps/ui/src/components/site-overview-view/overview-card.tsx
index b88a6f2172..fb40bcf6a7 100644
--- a/apps/ui/src/components/site-overview-view/overview-card.tsx
+++ b/apps/ui/src/components/site-overview-view/overview-card.tsx
@@ -2,9 +2,43 @@ import styles from './cards.module.css';
import type { ReactNode } from 'react';
export function OverviewCard( { children }: { children: ReactNode } ) {
- return
{ children }
;
+ return
;
}
-export function CardSection( { children }: { children: ReactNode } ) {
- return
;
+export function CardSection( {
+ title,
+ action,
+ children,
+}: {
+ title?: string;
+ action?: ReactNode;
+ children: ReactNode;
+} ) {
+ return (
+
+ { ( title || action ) && (
+
+ { title &&
{ title }
}
+ { action }
+
+ ) }
+ { children }
+
+ );
+}
+
+export function CardSectionDivider() {
+ return
;
+}
+
+export function CardEmptyState( { children }: { children: ReactNode } ) {
+ return
{ children }
;
+}
+
+export function CardRows( { children }: { children: ReactNode } ) {
+ return
{ children }
;
+}
+
+export function RowDivider() {
+ return
;
}
diff --git a/apps/ui/src/components/site-overview-view/row-link.tsx b/apps/ui/src/components/site-overview-view/row-link.tsx
new file mode 100644
index 0000000000..4db4c08a06
--- /dev/null
+++ b/apps/ui/src/components/site-overview-view/row-link.tsx
@@ -0,0 +1,40 @@
+import { sprintf, __ } from '@wordpress/i18n';
+import { Tooltip } from '@wordpress/ui';
+import { useConnector } from '@/data/core';
+import styles from './cards.module.css';
+
+export function RowLink( {
+ label,
+ url,
+ tooltip,
+}: {
+ label: string;
+ url: string;
+ tooltip?: string;
+} ) {
+ const connector = useConnector();
+
+ return (
+
+ void connector.openExternalUrl( url ) }
+ >
+ { label }
+
+ }
+ />
+ }>
+ { tooltip || url }
+
+
+ );
+}
diff --git a/apps/ui/src/hooks/use-is-site-syncing.ts b/apps/ui/src/hooks/use-is-site-syncing.ts
new file mode 100644
index 0000000000..cc680d2cd6
--- /dev/null
+++ b/apps/ui/src/hooks/use-is-site-syncing.ts
@@ -0,0 +1,21 @@
+import { useIsMutating } from '@tanstack/react-query';
+import {
+ PULL_FROM_LIVE_MUTATION_KEY,
+ PUSH_TO_LIVE_MUTATION_KEY,
+} from '@/data/queries/use-sync-site';
+
+export function useIsSiteSyncing( siteId: string ): { push: boolean; pull: boolean } {
+ const push =
+ useIsMutating( {
+ mutationKey: PUSH_TO_LIVE_MUTATION_KEY,
+ predicate: ( mutation ) =>
+ ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId,
+ } ) > 0;
+ const pull =
+ useIsMutating( {
+ mutationKey: PULL_FROM_LIVE_MUTATION_KEY,
+ predicate: ( mutation ) =>
+ ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId,
+ } ) > 0;
+ return { push, pull };
+}