diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css b/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css
deleted file mode 100644
index 81c84f9240..0000000000
--- a/apps/ui/src/components/site-dropdown/dropdown-trigger.module.css
+++ /dev/null
@@ -1,236 +0,0 @@
-.trigger {
- --site-dropdown-radius: var(--wpds-border-radius-lg);
- --site-menu-active-fill: var(--wpds-color-bg-interactive-neutral-strong);
- --site-menu-resting-fill: var(--wpds-color-bg-interactive-neutral-strong-active);
- --site-menu-foreground: var(--wpds-color-fg-interactive-neutral-strong);
- --wp-ui-button-background-color: var(--site-menu-resting-fill);
- --wp-ui-button-background-color-active: var(--site-menu-active-fill);
- --wp-ui-button-border-color: transparent;
- --wp-ui-button-border-color-active: transparent;
- --wp-ui-button-foreground-color: var(--site-menu-foreground);
- --wp-ui-button-foreground-color-active: var(--site-menu-foreground);
- --wp-ui-button-height: 44px;
- --wp-ui-button-padding-block: 0;
- --wp-ui-button-padding-inline: 0;
-
- gap: 12px;
- max-width: 100%;
- min-height: 44px;
- min-width: 0;
- align-items: center;
- backdrop-filter: blur(20px) saturate(115%);
- border-width: 0;
- border-radius: var(--site-dropdown-radius);
- box-shadow: 0 8px 18px rgb(0 0 0 / 18%);
- color: var(--site-menu-foreground);
- overflow: hidden;
- padding-inline: 0 12px;
- white-space: nowrap;
-}
-
-/* Non-floating placements (regular header rows) drop the floating-card
- shadow. */
-.trigger.triggerFlat {
- box-shadow: none;
-}
-
-.siteIconWrap {
- position: relative;
- display: inline-flex;
- flex: 0 0 auto;
- width: 44px;
- height: 44px;
-}
-
-.siteIcon {
- width: 100%;
- height: 100%;
- border-radius: 0;
-}
-
-.siteIcon_stopped {
- opacity: 0.72;
-}
-
-.identity {
- display: inline-flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: center;
- gap: 0;
- min-width: 0;
- max-width: min(420px, 56vw);
- padding-inline: 0 var(--wpds-dimension-padding-xs);
- text-align: left;
-}
-
-.site {
- flex: 0 1 auto;
- min-width: 0;
- color: var(--site-menu-foreground);
- overflow: hidden;
- font-weight: 600;
- line-height: var(--wpds-typography-line-height-sm);
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.secondary {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- max-width: 100%;
- min-width: 0;
- color: color-mix(in srgb, var(--site-menu-foreground) 78%, transparent);
- font-size: 11px;
- font-weight: 350;
- line-height: 1.05;
-}
-
-.secondaryLabel {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.secondary_pending {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 70%,
- var(--wpds-color-fg-interactive-brand)
- );
-}
-
-.secondary_success {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 72%,
- var(--wpds-color-fg-content-success, var(--site-menu-foreground))
- );
-}
-
-.secondary_error {
- color: color-mix(
- in srgb,
- var(--site-menu-foreground) 72%,
- var(--wpds-color-fg-content-error, var(--site-menu-foreground))
- );
-}
-
-.statusBadge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 8px;
- height: 8px;
- border-radius: 999px;
- flex-shrink: 0;
-}
-
-.statusBadge_overlay {
- position: absolute;
- right: -4px;
- bottom: 8px;
- z-index: 1;
- box-shadow: 0 0 0 2px var(--site-menu-resting-fill);
-}
-
-.statusBadge_live {
- background: #2563eb;
- color: var(--wpds-color-fg-content-inverted, #fff);
-}
-
-.statusBadge:not(.statusBadge_overlay).statusBadge_live {
- width: auto;
- height: 20px;
- gap: 4px;
- padding: 0 6px;
- background: color-mix(in srgb, #2563eb 9%, transparent);
- color: #2563eb;
-}
-
-.statusBadge_stopped {
- border-radius: 2px;
- background: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.statusBadge_transitioning {
- background: #2563eb;
- animation: siteDotBlink 1s ease-in-out infinite;
-}
-
-.dot {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- flex-shrink: 0;
-}
-
-.dot_running {
- background-color: var(--studio-color-status-running);
-}
-
-.dot_stopped {
- background-color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.dot_transitioning {
- background-color: #2563eb;
-}
-
-.dot_live {
- background-color: #2563eb;
-}
-
-.pauseMark,
-.pauseMark::before,
-.pauseMark::after {
- display: block;
- width: 1.5px;
- height: 4px;
- border-radius: 1px;
- background: var(--wpds-color-bg-surface-neutral-strong);
-}
-
-.pauseMark {
- position: relative;
- background: transparent;
-}
-
-.pauseMark::before,
-.pauseMark::after {
- position: absolute;
- top: 0;
- content: '';
-}
-
-.pauseMark::before {
- left: -2px;
-}
-
-.pauseMark::after {
- left: 1px;
-}
-
-.statusLabel {
- font-size: var(--wpds-typography-font-size-xs);
- font-weight: 500;
- line-height: 1;
-}
-
-@keyframes siteDotBlink {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.25;
- }
-}
-
-.chevron {
- flex: 0 0 auto;
- color: color-mix(in srgb, var(--site-menu-foreground) 72%, transparent);
- fill: currentColor;
-}
diff --git a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx b/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx
deleted file mode 100644
index 23a8a82f7d..0000000000
--- a/apps/ui/src/components/site-dropdown/dropdown-trigger.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-import { __ } from '@wordpress/i18n';
-import { chevronDownSmall } from '@wordpress/icons';
-import { Button, Icon, Tooltip } from '@wordpress/ui';
-import { clsx } from 'clsx';
-import { forwardRef } from 'react';
-import { SiteIcon } from '@/components/site-icon';
-import styles from './dropdown-trigger.module.css';
-import type { TriggerSecondaryTone } from './trigger-secondary';
-import type { ComponentProps, ElementRef } from 'react';
-
-export type SiteStatus = 'running' | 'stopped' | 'transitioning';
-
-type Props = Omit< ComponentProps< typeof Button >, 'children' > & {
- siteName: string;
- siteUrl: string;
- status: SiteStatus;
- statusLabel: string;
- environment: 'local' | 'live';
- secondaryLabel: string;
- secondaryTone?: TriggerSecondaryTone;
- showSiteIcon?: boolean;
- showStatus?: boolean;
- siteIconSeed?: string;
- siteIconImage?: string | null;
- // The floating-card shadow suits placements where the trigger overlays
- // panel content (the chat header); regular header rows pass false.
- floating?: boolean;
-};
-
-export const DropdownTrigger = forwardRef< ElementRef< typeof Button >, Props >(
- function DropdownTrigger(
- {
- siteName,
- siteUrl,
- status,
- statusLabel,
- environment,
- secondaryLabel,
- secondaryTone = 'neutral',
- showSiteIcon = false,
- showStatus = true,
- siteIconSeed,
- siteIconImage,
- floating = true,
- className,
- ...props
- },
- ref
- ) {
- // In live mode the local server's running/stopped status is irrelevant
- // to what the agent targets; use a dedicated dot color so the trigger
- // still reflects the active target.
- const isLive = environment === 'live';
- const dotClass = environment === 'live' ? styles.dot_live : styles[ `dot_${ status }` ];
- const statusClass =
- environment === 'live' ? styles.statusBadge_live : styles[ `statusBadge_${ status }` ];
- const dotLabel = isLive ? __( 'Live site' ) : statusLabel;
- const statusBadge = showStatus ? (
-
- { status === 'stopped' && ! isLive ? (
-
- ) : (
-
- ) }
- { ! showSiteIcon && isLive ? (
- { __( 'Live' ) }
- ) : null }
-
- ) : null;
-
- return (
-
- }
- className={ clsx( styles.trigger, ! floating && styles.triggerFlat, className ) }
- >
- { showSiteIcon ? (
-
-
- { statusBadge }
-
- ) : null }
-
- { siteName }
-
- { secondaryLabel }
-
-
- { showSiteIcon ? null : statusBadge }
-
-
- }>
- { __( 'Publish, preview, and more' ) }
-
-
- );
- }
-);
diff --git a/apps/ui/src/components/site-dropdown/index.tsx b/apps/ui/src/components/site-dropdown/index.tsx
deleted file mode 100644
index 96a5795446..0000000000
--- a/apps/ui/src/components/site-dropdown/index.tsx
+++ /dev/null
@@ -1,192 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-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 '@/components/selective-sync/selective-sync.css';
-import { useConnector } from '@/data/core';
-import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites';
-import { useIsSiteStarting, useIsSiteStopping } from '@/data/queries/use-sites';
-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';
-
-type Props = {
- site: SiteDetails;
- // Optional: when rendered inside a session view, the dropdown reflects the
- // session's active environment (local vs. live) rather than always reading
- // "Local". Outside a session context this defaults to local.
- activeEnvironment?: 'local' | 'live';
- showSiteIcon?: boolean;
- showStatus?: boolean;
- // The trigger casts a shadow when it floats over panel content (the chat
- // header). Pass false where it sits in a regular header row instead.
- floating?: boolean;
- defaultOpen?: boolean;
-};
-
-export function SiteDropdown( {
- site,
- activeEnvironment = 'local',
- showSiteIcon = false,
- showStatus = true,
- floating = true,
- defaultOpen = false,
-}: Props ) {
- const [ view, setView ] = useState< 'main' | 'picker' >( 'main' );
- const [ menuOpen, setMenuOpen ] = useState( defaultOpen );
- const [ disconnectOpen, setDisconnectOpen ] = useState( false );
- const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null );
-
- const connector = useConnector();
- const pushSiteToLive = usePushSiteToLive();
- const pullSiteFromLive = usePullSiteFromLive();
-
- // The copied selective-sync modules resolve their data calls through the
- // active connector (see selective-sync/lib/get-ipc-api.ts).
- useEffect( () => {
- registerSelectiveSyncConnector( connector );
- }, [ connector ] );
-
- // The trigger needs the site status for its running/stopped/transitioning
- // dot — everything else about status lives inside MainView.
- const isStarting = useIsSiteStarting( site.id );
- const isStopping = useIsSiteStopping( site.id );
- const { status, statusLabel } = deriveSiteStatus( site, isStarting, isStopping );
-
- // Only needed here so the disconnect dialog can reference the current live
- // site. MainView fetches the same data independently for its action row.
- const { data: connectedSites } = useConnectedWpcomSites( site.id );
- const { data: snapshots } = useSnapshots();
- const activity = useSiteSyncActivity( site.id );
- const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
- const previewSnapshot = useMemo(
- () => pickLatestSnapshot( snapshots, site.id ),
- [ snapshots, site.id ]
- );
- const secondary = useMemo(
- () =>
- getSiteDropdownSecondary( {
- activity,
- activeEnvironment,
- liveSite,
- previewSnapshot,
- } ),
- [ activity, activeEnvironment, liveSite, previewSnapshot ]
- );
-
- const handleDisconnectClick = () => {
- // Close the dropdown before showing the confirmation dialog so the two
- // overlays don't stack.
- setMenuOpen( false );
- setDisconnectOpen( true );
- };
-
- const openSyncDialog = ( type: 'push' | 'pull' ) => {
- // Same overlay rule as the disconnect dialog: dropdown closes first.
- setMenuOpen( false );
- setSyncDialogType( type );
- };
-
- const handleDialogPush = ( tree: TreeNode[] ) => {
- if ( ! liveSite ) return;
- const options = convertTreeToPushOptions( tree );
- pushSiteToLive.mutate(
- { siteId: site.id, remoteSiteId: liveSite.id, options },
- { onSuccess: () => void connector.openExternalUrl( 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 );
- };
-
- return (
-
-
{
- setMenuOpen( open );
- // Reset to the main view whenever the dropdown closes so the
- // next opening doesn't unexpectedly land in the picker state.
- if ( ! open ) {
- setView( 'main' );
- }
- } }
- >
-
- }
- />
-
- { view === 'main' ? (
- setView( 'picker' ) }
- onDisconnectClick={ handleDisconnectClick }
- onPullClick={ () => openSyncDialog( 'pull' ) }
- onPushClick={ () => openSyncDialog( 'push' ) }
- />
- ) : (
- setView( 'main' ) } />
- ) }
-
-
- { liveSite ? (
-
- ) : null }
- { liveSite && syncDialogType ? (
-
setSyncDialogType( null ) }
- />
- ) : null }
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/main-view.module.css b/apps/ui/src/components/site-dropdown/main-view.module.css
deleted file mode 100644
index f32c9de582..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.module.css
+++ /dev/null
@@ -1,368 +0,0 @@
-.rows {
- display: flex;
- flex-direction: column;
-}
-
-.rows > * + * {
- border-top: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
-}
-
-.activityStatus {
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
- color: var(--wpds-color-fg-content-neutral);
- font-size: var(--wpds-typography-font-size-sm);
- line-height: var(--wpds-typography-line-height-sm);
-}
-
-.activityStatusPending {
- background: var(--wpds-color-bg-surface-neutral);
-}
-
-.activityStatusError {
- background: color-mix(
- in srgb,
- var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral)) 8%,
- transparent
- );
-}
-
-.activityStatusTitle {
- font-weight: 600;
-}
-
-.activityStatusError .activityStatusTitle {
- color: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground, var(--wpds-color-fg-content-neutral)) 72%,
- var(--wpds-color-fg-content-error, var(--wpds-color-fg-content-neutral))
- );
-}
-
-.activityStatusMessage {
- color: var(--wpds-color-fg-content-neutral-weak);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.xdebugBadge {
- display: inline-flex;
- align-items: center;
- color: var(--studio-color-status-running);
-}
-
-.xdebugBadge_stopped {
- color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.xdebugGlyph {
- width: 16px;
- height: 16px;
-}
-
-.rowActions {
- display: flex;
- align-items: center;
- gap: 2px;
-}
-
-/* Derived from the popup's remapped tokens so the control tracks the
- inverted menu surface in both color schemes; scheme media queries
- would key off the OS instead and land backwards here. */
-.localServerControl {
- --local-server-track-bg: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 14%,
- transparent
- );
- --local-server-track-bg-hover: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 22%,
- transparent
- );
- --local-server-track-border: color-mix(
- in srgb,
- var(--wpds-color-stroke-surface-neutral) 72%,
- transparent
- );
- --local-server-track-border-hover: var(--wpds-color-stroke-surface-neutral);
- --local-server-track-shadow: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 6%,
- transparent
- );
- --local-server-thumb-bg: var(--wpds-color-fg-content-neutral);
- --local-server-thumb-color: var(--wpds-color-bg-surface-neutral-strong);
- --local-server-thumb-ring: color-mix(
- in srgb,
- var(--wpds-color-stroke-surface-neutral) 52%,
- transparent
- );
- --local-server-thumb-shadow: color-mix(
- in srgb,
- var(--wpds-color-fg-content-neutral) 14%,
- transparent
- );
-
- position: relative;
- display: inline-flex;
- align-items: center;
- box-sizing: border-box;
- width: 44px;
- height: 24px;
- margin: 0;
- padding: 0;
- border: 0;
- border-radius: 999px;
- appearance: none;
- background-color: var(--local-server-track-bg);
- color: var(--wpds-color-fg-content-neutral);
- cursor: var(--wpds-cursor-control);
- box-shadow:
- inset 0 0 0 var(--wpds-border-width-xs) var(--local-server-track-border),
- inset 0 1px 1px var(--local-server-track-shadow);
- transition:
- background-color 120ms ease,
- box-shadow 120ms ease;
-}
-
-.localServerControl_running {
- --local-server-track-bg: var(--studio-color-status-running);
- --local-server-track-bg-hover: #15803d;
- --local-server-track-border: #148a42;
- --local-server-track-border-hover: #137a3d;
- --local-server-track-shadow: color-mix(in srgb, #0d5f2c 26%, transparent);
- --local-server-thumb-bg: #fff;
- --local-server-thumb-color: #168a42;
- --local-server-thumb-ring: transparent;
- --local-server-thumb-shadow: color-mix(in srgb, #0d5f2c 22%, transparent);
-}
-
-.localServerControl:not([aria-disabled='true']):hover {
- background-color: var(--local-server-track-bg-hover);
- box-shadow:
- inset 0 0 0 var(--wpds-border-width-xs) var(--local-server-track-border-hover),
- inset 0 1px 1px var(--local-server-track-shadow);
-}
-
-.localServerControl:focus:not(:focus-visible):not(:hover) {
- background-color: var(--local-server-track-bg);
-}
-
-.localServerControl:focus-visible {
- background-color: var(--local-server-track-bg);
- outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
- outline-offset: 2px;
-}
-
-.localServerControl[aria-disabled='true'] {
- cursor: not-allowed;
- opacity: 0.72;
-}
-
-.localServerThumb {
- position: absolute;
- top: 2px;
- left: 2px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- box-sizing: border-box;
- width: 20px;
- height: 20px;
- border-radius: 999px;
- background-color: var(--local-server-thumb-bg);
- color: var(--local-server-thumb-color);
- line-height: 0;
- box-shadow:
- 0 1px 2px var(--local-server-thumb-shadow),
- 0 0 0 var(--wpds-border-width-xs) var(--local-server-thumb-ring);
- transition:
- transform 140ms ease,
- background-color 120ms ease,
- color 120ms ease,
- box-shadow 120ms ease;
-}
-
-.localServerControl_running .localServerThumb {
- transform: translateX(20px);
-}
-
-.localServerGlyph {
- display: block;
- transform-origin: center;
-}
-
-.localServerControl_pending .localServerGlyph {
- animation: localServerGlyphPulse 720ms ease-in-out infinite;
-}
-
-.pauseIcon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 2px;
- width: 11px;
- height: 11px;
-}
-
-.pauseIcon::before,
-.pauseIcon::after {
- content: '';
- width: 2px;
- height: 9px;
- border-radius: 1px;
- background: currentColor;
-}
-
-.playIcon {
- width: 9px;
- height: 10px;
- transform: translateX(1px);
- background: currentColor;
- clip-path: polygon(28% 18%, 28% 82%, 82% 50%);
-}
-
-.urlLink {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- max-width: 100%;
- margin: 0;
- padding: 0;
- border: 0;
- background: transparent;
- color: inherit;
- font: inherit;
- line-height: inherit;
- text-align: left;
- cursor: var(--wpds-cursor-control);
-}
-
-.urlLink:hover {
- color: var(--wpds-color-fg-content-neutral);
- text-decoration: underline;
- text-underline-offset: 2px;
-}
-
-.urlLink span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.urlLink svg {
- flex: 0 0 auto;
- fill: currentColor;
-}
-
-.environmentActionRow {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-gap-md);
- min-height: 40px;
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
-}
-
-.environmentActionText {
- flex: 1 1 auto;
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.environmentActionTitle {
- font-size: var(--wpds-typography-font-size-md);
- line-height: 1.25;
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.environmentActionCopy {
- margin: 0;
- font-size: var(--wpds-typography-font-size-xs);
- line-height: 1.4;
- color: var(--wpds-color-fg-content-neutral-weak);
-}
-
-.environmentActionButton {
- flex: 0 0 auto;
- align-self: center;
-}
-
-.rowActionButton,
-.rowViewButton,
-.environmentActionButton_outline {
- border-radius: var(--wpds-border-radius-md);
-}
-
-.rowActionButton,
-.rowViewButton {
- height: 28px;
-}
-
-.rowViewButton {
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.rowActionButton {
- width: 28px;
- padding: 0;
-}
-
-/* Only recolor enabled buttons — an unconditional `color` here sits outside
- the wp-ui layers and would override the design system's disabled dimming
- and the loading spinner's transparent-text state. */
-.rowActionButton:not([data-disabled]) {
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.rowActionButton svg {
- width: 14px;
- height: 14px;
- fill: currentColor;
-}
-
-.environmentActionButton_outline {
- --wp-ui-button-background-color: var(--wpds-color-bg-surface-neutral-strong);
- --wp-ui-button-background-color-active: var(--wpds-color-bg-surface-neutral);
- --wp-ui-button-border-color: var(--wpds-color-stroke-surface-neutral-weak);
- --wp-ui-button-border-color-active: var(--wpds-color-stroke-surface-neutral);
-}
-
-.moreMenuTrigger {
- width: 28px;
- height: 28px;
- padding: 0;
- justify-content: center;
- color: var(--wpds-color-fg-interactive-neutral);
-}
-
-.moreMenuTrigger:is([data-disabled], :disabled) {
- color: var(--wpds-color-fg-interactive-neutral-disabled);
-}
-
-.moreMenuTrigger svg {
- fill: currentColor;
-}
-
-.moreMenuPopup {
- min-width: 148px;
-}
-
-@keyframes localServerGlyphPulse {
- 0%,
- 100% {
- opacity: 0.92;
- transform: scale(1);
- }
-
- 50% {
- opacity: 0.45;
- transform: scale(0.78);
- }
-}
diff --git a/apps/ui/src/components/site-dropdown/main-view.test.tsx b/apps/ui/src/components/site-dropdown/main-view.test.tsx
deleted file mode 100644
index bd83f6fc03..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.test.tsx
+++ /dev/null
@@ -1,283 +0,0 @@
-import { useIsMutating } from '@tanstack/react-query';
-import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import * as Menu from '@/components/menu';
-import { MainView } from './main-view';
-import type { SiteDetails, Snapshot, SyncSite } from '@/data/core';
-import type { SyncActivity } from '@/data/sync-activity';
-
-const {
- connector,
- snapshots,
- connectedSites,
- publishPreviewMutate,
- transitions,
- startSiteMutate,
- stopSiteMutate,
-} = vi.hoisted( () => ( {
- connector: {
- copyText: vi.fn(),
- openExternalUrl: vi.fn(),
- },
- snapshots: [] as Snapshot[],
- connectedSites: [] as SyncSite[],
- publishPreviewMutate: vi.fn(),
- transitions: { starting: false, stopping: false },
- startSiteMutate: vi.fn(),
- stopSiteMutate: vi.fn(),
-} ) );
-
-vi.mock( '@tanstack/react-query', async ( importOriginal ) => {
- const actual = await importOriginal< typeof import('@tanstack/react-query') >();
- return {
- ...actual,
- useIsMutating: vi.fn( () => 0 ),
- };
-} );
-
-vi.mock( '@/data/core', () => ( {
- useConnector: () => connector,
-} ) );
-
-vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( {
- useConnectedWpcomSites: () => ( { data: connectedSites } ),
-} ) );
-
-vi.mock( '@/data/queries/use-agentic-features', () => ( {
- useAgenticFeatures: vi.fn( () => ( { enabled: true, reason: null, isReady: true } ) ),
-} ) );
-
-vi.mock( '@/data/queries/use-auth-user', () => ( {
- useLogin: () => ( { mutate: vi.fn(), isPending: false } ),
-} ) );
-
-vi.mock( '@/data/queries/use-preview-site', () => ( {
- usePublishPreviewSite: () => ( { isPending: false, mutate: publishPreviewMutate } ),
-} ) );
-
-vi.mock( '@/data/queries/use-sites', () => ( {
- useIsSiteStarting: () => transitions.starting,
- useIsSiteStopping: () => transitions.stopping,
- useStartSite: () => ( { mutate: startSiteMutate } ),
- useStopSite: () => ( { mutate: stopSiteMutate } ),
-} ) );
-
-vi.mock( '@/data/queries/use-snapshots', () => ( {
- useSnapshots: () => ( { data: snapshots } ),
-} ) );
-
-vi.mock( '@/data/queries/use-sync-site', () => ( {
- PULL_FROM_LIVE_MUTATION_KEY: [ 'pull-site-from-live' ],
- PUSH_TO_LIVE_MUTATION_KEY: [ 'push-site-to-live' ],
- usePullSiteFromLive: () => ( { mutate: vi.fn() } ),
- usePushSiteToLive: () => ( { mutate: vi.fn() } ),
-} ) );
-
-const liveSite: SyncSite = {
- id: 123,
- localSiteId: 'site-1',
- name: 'Live Site',
- url: 'example.com',
- isStaging: false,
- isPressable: false,
- syncSupport: 'already-connected',
- lastPullTimestamp: null,
- lastPushTimestamp: null,
-};
-
-const site: SiteDetails = {
- id: 'site-1',
- name: 'Demo Site',
- path: '/tmp/demo-site',
- port: 8881,
- running: true,
- phpVersion: '8.3',
-};
-
-function renderMainView( {
- siteOverrides = {},
- activity = null,
-}: {
- siteOverrides?: Partial< SiteDetails >;
- activity?: SyncActivity | null;
-} = {} ) {
- // The live row's "more" submenu needs the Menu.Root + Popup contexts the
- // dropdown provides around MainView in the real app.
- return render(
-
-
-
-
-
- );
-}
-
-describe( 'MainView', () => {
- beforeEach( () => {
- vi.mocked( useIsMutating ).mockImplementation( () => 0 );
- connector.copyText.mockReset();
- connector.openExternalUrl.mockReset();
- publishPreviewMutate.mockReset();
- startSiteMutate.mockReset();
- stopSiteMutate.mockReset();
- transitions.starting = false;
- transitions.stopping = false;
- snapshots.splice( 0, snapshots.length, {
- url: 'preview.example.com',
- atomicSiteId: 123,
- localSiteId: site.id,
- date: Date.now(),
- } );
- connectedSites.splice( 0, connectedSites.length );
- } );
-
- it( 'shows an Xdebug badge on the Studio row only when Xdebug is enabled', () => {
- const { unmount } = renderMainView( { siteOverrides: { enableXdebug: true } } );
-
- expect( screen.getByRole( 'img', { name: 'Xdebug enabled' } ) ).toBeInTheDocument();
-
- unmount();
- renderMainView();
-
- expect( screen.queryByRole( 'img', { name: 'Xdebug enabled' } ) ).not.toBeInTheDocument();
- } );
-
- it( 'labels the site status toggle with the status and the action it performs', () => {
- const { unmount } = renderMainView();
-
- const running = screen.getByRole( 'switch', { name: 'Site status: Running. Stop site' } );
- expect( running ).toBeChecked();
- fireEvent.click( running );
- expect( stopSiteMutate ).toHaveBeenCalledWith( site.id );
-
- unmount();
- renderMainView( { siteOverrides: { running: false } } );
-
- const stopped = screen.getByRole( 'switch', { name: 'Site status: Stopped. Start site' } );
- expect( stopped ).not.toBeChecked();
- fireEvent.click( stopped );
- expect( startSiteMutate ).toHaveBeenCalledWith( site.id );
- } );
-
- it( 'reports the pending status on the site status toggle without acting on clicks', () => {
- transitions.starting = true;
-
- const { unmount } = renderMainView( { siteOverrides: { running: false } } );
-
- const starting = screen.getByRole( 'switch', { name: 'Site status: Starting' } );
- expect( starting ).toHaveAttribute( 'aria-disabled', 'true' );
- expect( starting ).toBeChecked();
- fireEvent.click( starting );
- expect( startSiteMutate ).not.toHaveBeenCalled();
-
- unmount();
- transitions.starting = false;
- transitions.stopping = true;
- renderMainView();
-
- const stopping = screen.getByRole( 'switch', { name: 'Site status: Stopping' } );
- expect( stopping ).not.toBeChecked();
- fireEvent.click( stopping );
- expect( stopSiteMutate ).not.toHaveBeenCalled();
- } );
-
- it( 'handles preview URL copy failures', async () => {
- const error = new Error( 'Clipboard denied' );
- const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => undefined );
- connector.copyText.mockRejectedValueOnce( error );
-
- renderMainView();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Copy preview URL' } ) );
-
- await waitFor( () => {
- expect( connector.copyText ).toHaveBeenCalledWith( 'https://preview.example.com' );
- expect( consoleError ).toHaveBeenCalledWith( 'Failed to copy preview URL:', error );
- } );
-
- consoleError.mockRestore();
- } );
-
- it( 'shows detailed pull progress in the open site status', () => {
- renderMainView( {
- activity: {
- kind: 'pending',
- direction: 'pull',
- message: 'Creating remote backup… (24%)',
- progress: 24,
- },
- } );
-
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Pulling from live…' );
- expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Creating remote backup… (24%)' );
- } );
-
- it( 'updates the existing preview site while the snapshot is fresh', () => {
- renderMainView();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Update preview site' } ) );
-
- expect( publishPreviewMutate ).toHaveBeenCalledWith(
- expect.objectContaining( {
- siteId: site.id,
- existingHostname: 'preview.example.com',
- } ),
- expect.anything()
- );
- } );
-
- it( 'offers to share a new preview once the snapshot expired', () => {
- snapshots[ 0 ].date = Date.now() - 8 * 24 * 60 * 60 * 1000;
-
- renderMainView();
-
- expect( screen.getByText( 'The previous preview has expired.' ) ).toBeInTheDocument();
- expect( screen.queryByRole( 'button', { name: 'Copy preview URL' } ) ).not.toBeInTheDocument();
-
- fireEvent.click( screen.getByRole( 'button', { name: 'Share a new one' } ) );
-
- expect( publishPreviewMutate ).toHaveBeenCalledWith(
- expect.objectContaining( {
- siteId: site.id,
- existingHostname: undefined,
- } ),
- expect.anything()
- );
- } );
-
- it( 'labels the live sync controls with plain actions while idle', () => {
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView();
-
- const pullButton = screen.getByRole( 'button', { name: 'Pull from live' } );
- expect( pullButton.getAttribute( 'aria-disabled' ) ).not.toBe( 'true' );
- expect( screen.getByRole( 'button', { name: 'Push to live' } ) ).toBeInTheDocument();
- } );
-
- it( 'reflects an in-flight pull on both live sync controls', () => {
- vi.mocked( useIsMutating ).mockImplementation( ( filters ) =>
- filters?.mutationKey?.[ 0 ] === 'pull-site-from-live' ? 1 : 0
- );
- connectedSites.splice( 0, connectedSites.length, liveSite );
-
- renderMainView();
-
- const pullButton = screen.getByRole( 'button', { name: 'Pulling from live…' } );
- expect( pullButton ).toHaveAttribute( 'aria-disabled', 'true' );
-
- const pushButton = screen.getByRole( 'button', { name: 'Push to live (sync in progress)' } );
- expect( pushButton ).toHaveAttribute( 'aria-disabled', 'true' );
-
- expect(
- screen.getByRole( 'button', { name: 'Update preview site (sync in progress)' } )
- ).toBeInTheDocument();
- } );
-} );
diff --git a/apps/ui/src/components/site-dropdown/main-view.tsx b/apps/ui/src/components/site-dropdown/main-view.tsx
deleted file mode 100644
index 4df3e56491..0000000000
--- a/apps/ui/src/components/site-dropdown/main-view.tsx
+++ /dev/null
@@ -1,611 +0,0 @@
-import { TRACKS_EVENTS } from '@studio/common/lib/record-tracks-event';
-import { isSnapshotExpired } from '@studio/common/lib/snapshots';
-import { useIsMutating } from '@tanstack/react-query';
-import { __, sprintf } from '@wordpress/i18n';
-import { arrowDown, arrowUp, copy, external, Icon, moreHorizontal } from '@wordpress/icons';
-import { Button, IconButton, Tooltip } from '@wordpress/ui';
-import { clsx } from 'clsx';
-import { useMemo } from 'react';
-import * as Menu from '@/components/menu';
-import { XdebugIcon } from '@/components/xdebug-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 { usePublishPreviewSite } from '@/data/queries/use-preview-site';
-import {
- useIsSiteStarting,
- useIsSiteStopping,
- useStartSite,
- useStopSite,
-} from '@/data/queries/use-sites';
-import { useSnapshots } from '@/data/queries/use-snapshots';
-import {
- PULL_FROM_LIVE_MUTATION_KEY,
- PUSH_TO_LIVE_MUTATION_KEY,
-} from '@/data/queries/use-sync-site';
-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,
- 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';
-
-type ButtonProps = ComponentProps< typeof Button >;
-
-type Props = {
- site: SiteDetails;
- activity: SyncActivity | null;
- // Switches the dropdown to the publish picker. Lives in the parent because
- // the picker is a sibling view at the popup level.
- onSetupClick: () => void;
- // Opens the disconnect-site confirmation dialog; owned by the parent so the
- // dialog persists after the dropdown closes.
- onDisconnectClick: () => void;
- // Open the selective-sync dialog for pull/push; owned by the parent for the
- // same reason as the disconnect dialog.
- onPullClick: () => void;
- onPushClick: () => void;
-};
-
-// Counts in-flight push/pull mutations for this site across hook instances.
-// Needed because the parent kicks off a push from the publish-picker flow via
-// its own mutation instance — this component's Push button would otherwise
-// report "idle" while the picker-initiated push is still running.
-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 };
-}
-
-function getPreviewPanelCopy(
- agenticEnabled: boolean,
- isOffline: boolean,
- isPreviewExpired: boolean
-): string {
- if ( agenticEnabled ) {
- return isPreviewExpired
- ? __( 'The previous preview has expired.' )
- : __( 'Share a review link for this version.' );
- }
- if ( isOffline ) {
- return __( 'Go online to share a review link.' );
- }
- return __( 'Sign in to share a review link.' );
-}
-
-function getLivePanelCopy( agenticEnabled: boolean, isOffline: boolean ): string {
- if ( agenticEnabled ) {
- return __( 'No connected site.' );
- }
- if ( isOffline ) {
- return __( 'Go online to publish your site.' );
- }
- return __( 'Sign in to publish your site.' );
-}
-
-export function MainView( {
- site,
- activity,
- onSetupClick,
- onDisconnectClick,
- onPullClick,
- onPushClick,
-}: Props ) {
- const connector = useConnector();
- const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures();
- const isOffline = agenticReason === 'offline';
- const login = useLogin();
- const { data: snapshots } = useSnapshots();
- const { data: connectedSites } = useConnectedWpcomSites( site.id );
-
- const previewSnapshot = useMemo(
- () => pickLatestSnapshot( snapshots, site.id ),
- [ snapshots, site.id ]
- );
- const isPreviewExpired = previewSnapshot !== undefined && isSnapshotExpired( previewSnapshot );
- const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
-
- const startSite = useStartSite();
- const stopSite = useStopSite();
- const publishPreviewSite = usePublishPreviewSite();
-
- const isStarting = useIsSiteStarting( site.id );
- const isStopping = useIsSiteStopping( site.id );
- const isLocalTransitioning = isStarting || isStopping;
- const { push: isPushPending, pull: isPullPending } = useIsSiteSyncing( site.id );
- const isPreviewPending = publishPreviewSite.isPending;
- // Preview / push / pull all mutate the same local site; running them
- // concurrently would wedge the site runtime.
- const isSyncing = isPreviewPending || isPushPending || isPullPending;
-
- const { localSublabel } = deriveSiteStatus( site, isStarting, isStopping );
- const localSiteUrl = getSiteUrl( site );
- const canOpenLocalSite = site.running && ! isStopping;
-
- const openExternal = ( url: string ) => {
- void connector.openExternalUrl( url );
- };
-
- const getSyncActionLabel = ( idle: string, pending: string, isPending: boolean ): string => {
- if ( isPending ) {
- return pending;
- }
- if ( isSyncing ) {
- // translators: %s: a sync action, e.g. "Pull from live".
- return sprintf( __( '%s (sync in progress)' ), idle );
- }
- if ( ! agenticEnabled ) {
- return isOffline
- ? // translators: %s: a sync action, e.g. "Pull from live".
- sprintf( __( '%s (offline)' ), idle )
- : // translators: %s: a sync action, e.g. "Pull from live".
- sprintf( __( '%s (sign in required)' ), idle );
- }
- return idle;
- };
-
- const handlePreviewClick = () => {
- if ( isPreviewPending ) return;
- publishPreviewSite.mutate(
- {
- siteId: site.id,
- // The CLI cannot update an expired preview site — create a new one.
- existingHostname:
- previewSnapshot && ! isPreviewExpired
- ? getSnapshotHostname( previewSnapshot )
- : undefined,
- },
- { onSuccess: ( { url } ) => openExternal( ensureProtocol( url ) ) }
- );
- };
-
- const handleCopyPreviewClick = ( url: string ) => {
- void connector.copyText( url ).catch( ( error ) => {
- console.error( 'Failed to copy preview URL:', error );
- } );
- };
-
- const handleStartLocalClick = () => {
- if ( isLocalTransitioning || isSyncing || site.running ) return;
- startSite.mutate( site.id );
- };
-
- const handleStopLocalClick = () => {
- if ( isLocalTransitioning || isSyncing || ! site.running ) return;
- stopSite.mutate( site.id );
- };
-
- const handlePullClick = () => {
- if ( ! liveSite || isSyncing ) return;
- onPullClick();
- };
-
- const handlePushClick = () => {
- if ( ! liveSite || isSyncing ) return;
- onPushClick();
- };
-
- const renderTooltipButton = ( {
- tooltip,
- children,
- ...props
- }: ButtonProps & { tooltip: string } ) => (
-
- { children } } />
- }>{ tooltip }
-
- );
-
- const renderUrlLink = ( {
- text,
- url,
- label,
- onOpen,
- }: {
- text: string;
- url: string;
- label: string;
- onOpen?: () => void;
- } ) => (
-
- {
- onOpen?.();
- openExternal( url );
- } }
- >
- { text }
-
-
- }
- />
- }>{ label }
-
- );
-
- return (
-
- { activity?.kind === 'pending' || activity?.kind === 'error' ? (
-
- ) : null }
-
-
- { __( 'Studio' ) }
-
- >
- ) : (
- __( 'Studio' )
- )
- }
- sublabel={
- canOpenLocalSite
- ? renderUrlLink( {
- text: localSublabel,
- url: localSiteUrl,
- label: __( 'Open Studio site in your browser' ),
- onOpen: () =>
- void connector.trackEvent( TRACKS_EVENTS.SITE_OPEN_IN_BROWSER, {
- browser: 'external',
- } ),
- } )
- : localSublabel
- }
- action={
-
- }
- />
-
- { previewSnapshot && ! isPreviewExpired ? (
-
- { renderTooltipButton( {
- tooltip: __( 'Open preview site in your browser' ),
- variant: 'minimal',
- tone: 'neutral',
- size: 'small',
- className: styles.rowViewButton,
- onClick: () => openExternal( ensureProtocol( previewSnapshot.url ) ),
- children: __( 'View' ),
- } ) }
- handleCopyPreviewClick( ensureProtocol( previewSnapshot.url ) ) }
- />
-
-
- }
- />
- ) : (
-
- ) }
-
- { liveSite ? (
-
-
-
-
-
-
-
-
-
- { __( 'Disconnect' ) }
-
-
-
-
- }
- />
- ) : (
- login.mutate() }
- />
- ) }
-
- );
-}
-
-function XdebugBadge( { running }: { running: boolean } ) {
- const label = __( 'Xdebug enabled' );
-
- return (
-
-
- }
- >
-
-
- }>{ label }
-
- );
-}
-
-function SyncActivityDetails( {
- activity,
-}: {
- activity: Extract< SyncActivity, { kind: 'pending' | 'error' } >;
-} ) {
- return (
-
-
{ getSyncActivityLabel( activity ) }
-
- { activity.message ?? __( 'Preparing the live site…' ) }
-
-
- );
-}
-
-function getLocalServerStatusName( {
- running,
- starting,
- stopping,
-}: {
- running: boolean;
- starting: boolean;
- stopping: boolean;
-} ) {
- if ( stopping ) {
- return __( 'Stopping' );
- }
- if ( starting ) {
- return __( 'Starting' );
- }
- return running ? __( 'Running' ) : __( 'Stopped' );
-}
-
-function LocalServerControl( {
- running,
- starting,
- stopping,
- disabled,
- onStart,
- onStop,
-}: {
- running: boolean;
- starting: boolean;
- stopping: boolean;
- disabled: boolean;
- onStart: () => void;
- onStop: () => void;
-} ) {
- const pending = starting || stopping;
- const targetRunning = starting ? true : stopping ? false : running;
- // aria-disabled rather than disabled: a natively disabled button suppresses
- // the pointer events the tooltip listens for, hiding the status exactly
- // while the site is transitioning.
- const inert = disabled || pending;
- const statusLabel = sprintf(
- __( 'Site status: %s' ),
- getLocalServerStatusName( { running, starting, stopping } )
- );
- const actionLabel = running ? __( 'Stop site' ) : __( 'Start site' );
-
- return (
-
- {
- if ( inert ) {
- return;
- }
- if ( targetRunning ) {
- onStop();
- } else {
- onStart();
- }
- } }
- >
-
-
-
-
- }
- />
- }>
- { statusLabel }
-
-
- );
-}
-
-function EnvironmentActionPanel( {
- title,
- copy,
- buttonLabel,
- variant,
- tone,
- loading,
- loadingAnnouncement,
- disabled,
- onClick,
-}: {
- title: string;
- copy: string;
- buttonLabel: string;
- variant: ButtonProps[ 'variant' ];
- tone: ButtonProps[ 'tone' ];
- loading?: boolean;
- loadingAnnouncement?: string;
- disabled: boolean;
- onClick: () => void;
-} ) {
- return (
-
-
-
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/popover-row.module.css b/apps/ui/src/components/site-dropdown/popover-row.module.css
deleted file mode 100644
index af8ff09600..0000000000
--- a/apps/ui/src/components/site-dropdown/popover-row.module.css
+++ /dev/null
@@ -1,46 +0,0 @@
-.row {
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-md);
- padding: var(--wpds-dimension-padding-md) var(--wpds-dimension-padding-lg);
- min-height: 40px;
-}
-
-.text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
-}
-
-.label {
- /* Allow callers to tuck an inline affordance (e.g. an "open external"
- IconButton) right next to the label text. A plain text label still
- renders correctly inside this flex row. */
- display: flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-xs);
- font-size: var(--wpds-typography-font-size-md);
- line-height: 1.25;
- font-weight: 500;
- color: var(--wpds-color-fg-content-neutral);
-}
-
-.sublabel {
- margin-top: 4px;
- font-size: var(--wpds-typography-font-size-xs);
- line-height: 1.3;
- color: var(--wpds-color-fg-content-neutral-weak);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.action {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--wpds-color-fg-interactive-brand);
- font-size: var(--wpds-typography-font-size-sm);
-}
diff --git a/apps/ui/src/components/site-dropdown/popover-row.tsx b/apps/ui/src/components/site-dropdown/popover-row.tsx
deleted file mode 100644
index 79814466ca..0000000000
--- a/apps/ui/src/components/site-dropdown/popover-row.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import styles from './popover-row.module.css';
-import type { ReactNode } from 'react';
-
-type Props = {
- label: ReactNode;
- sublabel?: ReactNode;
- action?: ReactNode;
-};
-
-export function PopoverRow( { label, sublabel, action }: Props ) {
- return (
-
-
-
{ label }
- { sublabel ?
{ sublabel }
: null }
-
- { action ?
{ action }
: null }
-
- );
-}
diff --git a/apps/ui/src/components/site-dropdown/style.module.css b/apps/ui/src/components/site-dropdown/style.module.css
deleted file mode 100644
index ab64f95d25..0000000000
--- a/apps/ui/src/components/site-dropdown/style.module.css
+++ /dev/null
@@ -1,92 +0,0 @@
-.root {
- display: inline-flex;
- align-items: center;
- gap: var(--wpds-dimension-padding-lg);
- min-width: 0;
-}
-
-/* The menu renders on the same inverted surface as the trigger, so the two
- read as one connected control. Remapping the wpds tokens locally lets the
- existing token-based row styles adapt without per-element overrides. */
-.popup {
- --site-dropdown-radius: var(--wpds-border-radius-lg);
- --site-dropdown-menu-surface: var(--wpds-color-bg-interactive-neutral-strong-active);
- --site-dropdown-menu-surface-subtle: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 7%,
- var(--site-dropdown-menu-surface)
- );
- --site-dropdown-menu-surface-active: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 13%,
- var(--site-dropdown-menu-surface)
- );
- --site-dropdown-menu-foreground: var(--wpds-color-fg-interactive-neutral-strong);
- --site-dropdown-menu-foreground-weak: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 72%,
- transparent
- );
- --site-dropdown-menu-foreground-disabled: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 38%,
- transparent
- );
- --site-dropdown-menu-divider: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 16%,
- transparent
- );
- --site-dropdown-menu-border: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 11%,
- transparent
- );
- --site-dropdown-menu-control-border: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 48%,
- transparent
- );
- --site-dropdown-menu-control-border-active: color-mix(
- in srgb,
- var(--site-dropdown-menu-foreground) 68%,
- transparent
- );
- --site-dropdown-menu-hover: var(--site-dropdown-menu-surface-active);
- /* Brand fg flips with the theme while this surface is theme-inverted, so
- derive a link blue from the constant brand fill instead: periwinkle on
- the dark surface, royal navy on the light one. */
- --site-dropdown-menu-brand: color-mix(
- in srgb,
- var(--wpds-color-bg-interactive-brand-strong, #3858e9) 65%,
- var(--site-dropdown-menu-foreground)
- );
- --wpds-color-bg-surface-neutral: var(--site-dropdown-menu-surface-subtle);
- --wpds-color-bg-surface-neutral-strong: var(--site-dropdown-menu-surface);
- --wpds-color-bg-interactive-neutral-weak: transparent;
- --wpds-color-bg-interactive-neutral-weak-active: var(--site-dropdown-menu-hover);
- --wpds-color-bg-interactive-brand-weak-active: var(--site-dropdown-menu-hover);
- --wpds-color-fg-interactive-brand: var(--site-dropdown-menu-brand);
- --wpds-color-fg-content-neutral: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-content-neutral-weak: var(--site-dropdown-menu-foreground-weak);
- --wpds-color-fg-interactive-neutral: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-interactive-neutral-active: var(--site-dropdown-menu-foreground);
- --wpds-color-fg-interactive-neutral-disabled: var(--site-dropdown-menu-foreground-disabled);
- --wpds-color-stroke-focus-brand: var(--site-dropdown-menu-control-border-active);
- --wpds-color-stroke-interactive-neutral: var(--site-dropdown-menu-control-border);
- --wpds-color-stroke-interactive-neutral-active: var(
- --site-dropdown-menu-control-border-active
- );
- --wpds-color-stroke-surface-neutral: var(--site-dropdown-menu-divider);
- --wpds-color-stroke-surface-neutral-weak: var(--site-dropdown-menu-border);
-
- width: min(336px, calc(100vw - 32px));
- padding: 0;
- border: var(--wpds-border-width-xs) solid var(--site-dropdown-menu-border);
- border-radius: var(--site-dropdown-radius);
- background: var(--site-dropdown-menu-surface);
- color: var(--site-dropdown-menu-foreground);
- gap: 0;
- overflow: visible;
- box-shadow: 0 18px 44px rgb(0 0 0 / 24%);
-}
diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts
deleted file mode 100644
index 21f421bf7f..0000000000
--- a/apps/ui/src/components/site-dropdown/trigger-secondary.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { getSiteDropdownSecondary, getSyncActivityLabel } from './trigger-secondary';
-import type { Snapshot, SyncSite } from '@/data/core';
-
-const NOW = '2026-05-03T12:00:00.000Z';
-
-describe( 'getSiteDropdownSecondary', () => {
- beforeEach( () => {
- vi.useFakeTimers();
- vi.setSystemTime( new Date( NOW ) );
- } );
-
- afterEach( () => {
- vi.useRealTimers();
- } );
-
- it( 'prioritizes active sync activity over persisted site context', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: { kind: 'pending', direction: 'preview' },
- activeEnvironment: 'local',
- liveSite: createLiveSite( { lastPushTimestamp: '2026-05-03T11:00:00.000Z' } ),
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-05-03T10:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Publishing preview…',
- tone: 'pending',
- } );
- } );
-
- it( 'shows preview recency while working locally', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-05-03T10:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Preview updated 2h ago',
- tone: 'neutral',
- } );
- } );
-
- it( 'reports expiry instead of recency once the snapshot is too old', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- previewSnapshot: createSnapshot( { date: Date.parse( '2026-04-25T12:00:00.000Z' ) } ),
- } )
- ).toEqual( {
- label: 'Preview expired',
- tone: 'neutral',
- } );
- } );
-
- it( 'uses live push recency while the session targets live', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'live',
- liveSite: createLiveSite( { lastPushTimestamp: '2026-05-03T09:00:00.000Z' } ),
- } )
- ).toEqual( {
- label: 'Pushed 3h ago',
- tone: 'neutral',
- } );
- } );
-
- it( 'falls back to the active environment when no recency exists', () => {
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'local',
- } )
- ).toEqual( {
- label: 'Local preview',
- tone: 'neutral',
- } );
-
- expect(
- getSiteDropdownSecondary( {
- activity: null,
- activeEnvironment: 'live',
- } )
- ).toEqual( {
- label: 'Live site',
- tone: 'neutral',
- } );
- } );
-} );
-
-describe( 'getSyncActivityLabel', () => {
- it( 'formats error labels by direction', () => {
- expect( getSyncActivityLabel( { kind: 'error', direction: 'push', message: 'Nope' } ) ).toBe(
- 'Pushing to live failed'
- );
- expect( getSyncActivityLabel( { kind: 'error', direction: 'pull', message: 'Nope' } ) ).toBe(
- 'Pulling from live failed'
- );
- expect( getSyncActivityLabel( { kind: 'error', direction: 'preview', message: 'Nope' } ) ).toBe(
- 'Publishing preview failed'
- );
- } );
-} );
-
-function createSnapshot( overrides: Partial< Snapshot > = {} ): Snapshot {
- return {
- atomicSiteId: 123,
- localSiteId: 'site-1',
- url: 'example.preview.wordpress.com',
- date: Date.parse( NOW ),
- ...overrides,
- };
-}
-
-function createLiveSite( overrides: Partial< SyncSite > = {} ): SyncSite {
- return {
- id: 123,
- localSiteId: 'site-1',
- name: 'Live Site',
- url: 'example.com',
- isStaging: false,
- isPressable: false,
- syncSupport: 'already-connected',
- lastPullTimestamp: null,
- lastPushTimestamp: null,
- ...overrides,
- };
-}
diff --git a/apps/ui/src/components/site-dropdown/trigger-secondary.ts b/apps/ui/src/components/site-dropdown/trigger-secondary.ts
deleted file mode 100644
index 658935bfed..0000000000
--- a/apps/ui/src/components/site-dropdown/trigger-secondary.ts
+++ /dev/null
@@ -1,159 +0,0 @@
-import { isSnapshotExpired } from '@studio/common/lib/snapshots';
-import { __, sprintf } from '@wordpress/i18n';
-import { formatRelativeTime } from '@/lib/format-relative-time';
-import type { Snapshot, SyncSite } from '@/data/core';
-import type { SyncActivity } from '@/data/sync-activity';
-
-const MINUTE_MS = 60_000;
-
-export type TriggerSecondaryTone = 'neutral' | 'pending' | 'success' | 'error';
-
-export type TriggerSecondary = {
- label: string;
- tone: TriggerSecondaryTone;
-};
-
-type TriggerSecondaryOptions = {
- activity: SyncActivity | null;
- activeEnvironment: 'local' | 'live';
- liveSite?: SyncSite;
- previewSnapshot?: Snapshot;
-};
-
-export function getSyncActivityLabel( activity: SyncActivity ): string {
- if ( activity.kind === 'pending' ) {
- if ( activity.direction === 'preview' ) {
- return __( 'Publishing preview…' );
- }
- return activity.direction === 'push' ? __( 'Pushing to live…' ) : __( 'Pulling from live…' );
- }
-
- if ( activity.kind === 'success' ) {
- if ( activity.direction === 'preview' ) {
- return __( 'Preview published' );
- }
- return activity.direction === 'push' ? __( 'Pushed to live' ) : __( 'Pulled from live' );
- }
-
- if ( activity.direction === 'preview' ) {
- return __( 'Publishing preview failed' );
- }
- return activity.direction === 'push'
- ? __( 'Pushing to live failed' )
- : __( 'Pulling from live failed' );
-}
-
-function getSyncActivityTone( activity: SyncActivity ): TriggerSecondaryTone {
- if ( activity.kind === 'pending' ) {
- return 'pending';
- }
- return activity.kind === 'success' ? 'success' : 'error';
-}
-
-function formatTimestampPhrase(
- timestampMs: number,
- nowLabel: string,
- formatAgo: ( relativeTime: string ) => string
-): string | null {
- if ( ! Number.isFinite( timestampMs ) ) {
- return null;
- }
-
- const timestamp = new Date( timestampMs );
- if ( Number.isNaN( timestamp.getTime() ) ) {
- return null;
- }
-
- if ( Math.max( 0, Date.now() - timestampMs ) < MINUTE_MS ) {
- return nowLabel;
- }
-
- return formatAgo( formatRelativeTime( timestamp.toISOString() ) );
-}
-
-function formatIsoTimestampPhrase(
- isoTimestamp: string | null | undefined,
- nowLabel: string,
- formatAgo: ( relativeTime: string ) => string
-): string | null {
- if ( ! isoTimestamp ) {
- return null;
- }
-
- return formatTimestampPhrase( Date.parse( isoTimestamp ), nowLabel, formatAgo );
-}
-
-function getPreviewLabel( previewSnapshot: Snapshot | undefined ): string | null {
- if ( ! previewSnapshot ) {
- return null;
- }
-
- if ( isSnapshotExpired( previewSnapshot ) ) {
- return __( 'Preview expired' );
- }
-
- return formatTimestampPhrase(
- previewSnapshot.date,
- __( 'Preview updated now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Preview updated %s ago' ),
- relativeTime
- )
- );
-}
-
-function getPushLabel( liveSite: SyncSite | undefined ): string | null {
- return formatIsoTimestampPhrase(
- liveSite?.lastPushTimestamp,
- __( 'Pushed just now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Pushed %s ago' ),
- relativeTime
- )
- );
-}
-
-function getPullLabel( liveSite: SyncSite | undefined ): string | null {
- return formatIsoTimestampPhrase(
- liveSite?.lastPullTimestamp,
- __( 'Pulled just now' ),
- ( relativeTime ) =>
- sprintf(
- // translators: %s: compact relative time, e.g. "4m" or "2h".
- __( 'Pulled %s ago' ),
- relativeTime
- )
- );
-}
-
-export function getSiteDropdownSecondary( {
- activity,
- activeEnvironment,
- liveSite,
- previewSnapshot,
-}: TriggerSecondaryOptions ): TriggerSecondary {
- if ( activity ) {
- return {
- label: getSyncActivityLabel( activity ),
- tone: getSyncActivityTone( activity ),
- };
- }
-
- const liveSyncLabel = getPushLabel( liveSite ) ?? getPullLabel( liveSite );
-
- if ( activeEnvironment === 'live' ) {
- return {
- label: liveSyncLabel ?? __( 'Live site' ),
- tone: 'neutral',
- };
- }
-
- return {
- label: getPreviewLabel( previewSnapshot ) ?? liveSyncLabel ?? __( 'Local preview' ),
- tone: 'neutral',
- };
-}
diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx
index 85d53b68b0..fc3c54272b 100644
--- a/apps/ui/src/components/site-list/index.tsx
+++ b/apps/ui/src/components/site-list/index.tsx
@@ -23,7 +23,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 } from '@/components/site-dropdown/utils';
+import { deriveSiteRunStatus } from '@/components/site-status-button';
import { XdebugIcon } from '@/components/xdebug-icon';
import { useConnector } from '@/data/core';
import { useSiteAgentActivity, type SiteAgentActivity } from '@/data/queries/use-agent-run';
@@ -239,7 +239,7 @@ function SiteStatusButton( {
} ) {
const startSite = useStartSite();
const stopSite = useStopSite();
- const { status } = deriveSiteStatus( site, isStarting, isStopping );
+ const status = deriveSiteRunStatus( { site, isStarting, isStopping } );
const busy = isStarting || isStopping;
const statusName =
status === 'running'
@@ -528,7 +528,7 @@ function SiteSection( {
}, [ isActive ] );
const isStarting = useIsSiteStarting( site.id );
const isStopping = useIsSiteStopping( site.id );
- const { status } = deriveSiteStatus( site, isStarting, isStopping );
+ const status = deriveSiteRunStatus( { site, isStarting, isStopping } );
const agentActivity = useSiteAgentActivity( row.sessionIds );
const syncActivity = useSiteSyncActivity( site.id );
const isLiveSyncPending =
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 a960dfd0a5..da98af0bce 100644
--- a/apps/ui/src/components/site-overview-view/index.test.tsx
+++ b/apps/ui/src/components/site-overview-view/index.test.tsx
@@ -31,7 +31,7 @@ import type {
} from '@/data/core';
const navigateMock = vi.fn();
-const siteDropdownMock = vi.hoisted( () => vi.fn() );
+const siteToolbarMock = vi.hoisted( () => vi.fn() );
const useSidebarCollapsedMock = vi.hoisted( () => vi.fn() );
const useTrafficLightSpaceMock = vi.hoisted( () => vi.fn() );
@@ -56,14 +56,9 @@ vi.mock( '@/components/delete-site-dialog', () => ( {
open ? Delete dialog
: null,
} ) );
-vi.mock( '@/components/site-dropdown', () => ( {
- SiteDropdown: ( props: {
- site: SiteDetails;
- showSiteIcon?: boolean;
- showStatus?: boolean;
- defaultOpen?: boolean;
- } ) => {
- siteDropdownMock( props );
+vi.mock( '@/components/site-toolbar', () => ( {
+ SiteToolbar: ( props: { site: SiteDetails; className?: string; openPullOnLoad?: boolean } ) => {
+ siteToolbarMock( props );
return { props.site.name }
;
},
} ) );
@@ -235,14 +230,14 @@ describe( 'SiteOverviewView', () => {
function renderView(
activeTab: 'overview' | 'general' | 'debugging' = 'overview',
- openSiteDropdown = false
+ openPullOnLoad = false
) {
return render(
@@ -252,8 +247,8 @@ describe( 'SiteOverviewView', () => {
it( 'renders the tab strip with the about, customize, and manage sections', () => {
renderView();
- expect( siteDropdownMock ).toHaveBeenCalledWith(
- expect.objectContaining( { showSiteIcon: true, showStatus: false } )
+ expect( siteToolbarMock ).toHaveBeenCalledWith(
+ expect.objectContaining( { site: expect.objectContaining( { id: 'site-1' } ) } )
);
expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible();
expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible();
@@ -344,11 +339,11 @@ describe( 'SiteOverviewView', () => {
expect( onTabChange ).toHaveBeenCalledWith( 'general' );
} );
- it( 'opens site status when requested by the route', () => {
+ it( 'opens the pull dialog when requested by the route', () => {
renderView( 'overview', true );
- expect( siteDropdownMock ).toHaveBeenCalledWith(
- expect.objectContaining( { defaultOpen: true } )
+ expect( siteToolbarMock ).toHaveBeenCalledWith(
+ expect.objectContaining( { openPullOnLoad: true } )
);
} );
diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx
index f20200d1f9..325756374d 100644
--- a/apps/ui/src/components/site-overview-view/index.tsx
+++ b/apps/ui/src/components/site-overview-view/index.tsx
@@ -24,9 +24,9 @@ import { OfflineBanner } from '@/components/offline-banner';
import { useOpenInDestinations } from '@/components/open-in-menu/use-open-in-destinations';
import { PreviewToggleButton } from '@/components/preview-toggle-button';
import { ProgressiveBlur } from '@/components/progressive-blur';
-import { SiteDropdown } from '@/components/site-dropdown';
import { DATABASE_HOME_PATH } from '@/components/site-preview/address-bar';
import { isSiteSettingsTab, SiteSettingsForm } from '@/components/site-settings-view';
+import { SiteToolbar } from '@/components/site-toolbar';
import * as Tabs from '@/components/tabs';
import { useConnector } from '@/data/core';
import { useIsSiteStarting, useIsSiteStopping, useSites } from '@/data/queries/use-sites';
@@ -47,7 +47,7 @@ import type { ReactNode } from 'react';
interface SiteOverviewViewProps {
siteId: string;
activeTab: SiteSettingsTabId;
- openSiteDropdown?: boolean;
+ openPullOnLoad?: boolean;
onTabChange: ( tab: SiteSettingsTabId ) => void;
}
@@ -64,10 +64,10 @@ interface OverviewButtonProps {
function OverviewHeader( {
site,
- openSiteDropdown,
+ openPullOnLoad,
}: {
site: SiteDetails;
- openSiteDropdown: boolean;
+ openPullOnLoad: boolean;
} ) {
const sidebarCollapsed = useSidebarCollapsed();
const reserveTrafficLightSpace = useTrafficLightSpace().start;
@@ -80,13 +80,7 @@ function OverviewHeader( {
: styles.header
}
>
-
+
);
}
@@ -188,7 +182,7 @@ function OpenInSection( {
export function SiteOverviewView( {
siteId,
activeTab,
- openSiteDropdown = false,
+ openPullOnLoad = false,
onTabChange,
}: SiteOverviewViewProps ) {
const { data: sites, isLoading: sitesLoading } = useSites();
@@ -211,7 +205,7 @@ export function SiteOverviewView( {
);
@@ -220,12 +214,12 @@ export function SiteOverviewView( {
function SiteOverviewBody( {
site,
activeTab,
- openSiteDropdown,
+ openPullOnLoad,
onTabChange,
}: {
site: SiteDetails;
activeTab: SiteSettingsTabId;
- openSiteDropdown: boolean;
+ openPullOnLoad: boolean;
onTabChange: ( tab: SiteSettingsTabId ) => void;
} ) {
const navigate = useNavigate();
@@ -257,7 +251,7 @@ function SiteOverviewBody( {
return (
-
+
;
+ isStarting: boolean;
+ isStopping: boolean;
+} ): SiteRunStatus {
+ if ( isStarting || isStopping ) {
+ return 'transitioning';
+ }
+ return site.running ? 'running' : 'stopped';
+}
+
+interface SiteStatusButtonProps {
+ site: SiteDetails;
+ isStarting: boolean;
+ isStopping: boolean;
+ className?: string;
+}
+
+/**
+ * The site's running state and its start/stop control in one 24px target: a
+ * dot while idle, crossfading to the action it triggers on hover. When Xdebug
+ * is on, its bug replaces the dot entirely — a persistent per-site setting
+ * worth spotting at a glance — while keeping the same state colors.
+ *
+ * Shared by the sidebar rows and the site toolbar so the two never drift.
+ */
+export function SiteStatusButton( {
+ site,
+ isStarting,
+ isStopping,
+ className,
+}: SiteStatusButtonProps ) {
+ const startSite = useStartSite();
+ const stopSite = useStopSite();
+ const status = deriveSiteRunStatus( { site, isStarting, isStopping } );
+ const busy = isStarting || isStopping;
+ const statusName =
+ status === 'running'
+ ? __( 'Running' )
+ : status === 'transitioning'
+ ? isStopping
+ ? __( 'Stopping' )
+ : __( 'Starting' )
+ : __( 'Stopped' );
+ const xdebug = Boolean( site.enableXdebug );
+ const tooltipLabel = xdebug
+ ? sprintf( __( 'Site status: %s. Xdebug enabled' ), statusName )
+ : sprintf( __( 'Site status: %s' ), statusName );
+ const actionLabel = site.running ? __( 'Stop site' ) : __( 'Start site' );
+ const label = busy ? tooltipLabel : sprintf( __( '%1$s. %2$s' ), tooltipLabel, actionLabel );
+
+ const handleClick = ( event: MouseEvent< HTMLButtonElement > ) => {
+ event.stopPropagation();
+ if ( busy ) {
+ return;
+ }
+ if ( site.running ) {
+ stopSite.mutate( site.id );
+ } else {
+ startSite.mutate( site.id );
+ }
+ };
+
+ return (
+
+
+ { xdebug ? (
+
+ ) : (
+
+ ) }
+ { ! busy ? (
+ site.running ? (
+
+
+
+ ) : (
+
+ )
+ ) : null }
+
+ }
+ />
+ }>
+ { tooltipLabel }
+
+
+ );
+}
diff --git a/apps/ui/src/components/site-status-button/style.module.css b/apps/ui/src/components/site-status-button/style.module.css
new file mode 100644
index 0000000000..e80c10c43a
--- /dev/null
+++ b/apps/ui/src/components/site-status-button/style.module.css
@@ -0,0 +1,168 @@
+.status {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 24px;
+ inline-size: 24px;
+ block-size: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: inherit;
+ cursor: var(--wpds-cursor-control);
+ transition: background-color 100ms ease, opacity 100ms ease;
+}
+
+.status:not([aria-disabled='true']):is(:hover, :focus-visible) {
+ background-color: var(--wpds-color-bg-interactive-neutral-weak-active);
+}
+
+.status:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9);
+ outline-offset: -2px;
+}
+
+.status[aria-disabled='true'] {
+ cursor: default;
+}
+
+.status .glyph.glyph {
+ grid-row-start: 1;
+ grid-column-start: 1;
+ inline-size: 8px;
+ block-size: 8px;
+ width: 8px;
+ height: 8px;
+ flex-shrink: 0;
+ overflow: visible;
+ transition: opacity 100ms ease;
+}
+
+/* The Xdebug bug replaces the status dot; its color tracks the same state
+ palette the dot uses. */
+.status .xdebugGlyph.xdebugGlyph {
+ inline-size: 20px;
+ block-size: 20px;
+ width: 20px;
+ height: 20px;
+}
+
+.status[data-state='running'] .xdebugGlyph {
+ color: var(--studio-color-status-running);
+}
+
+.status[data-state='transitioning'] .xdebugGlyph {
+ color: var(--studio-color-status-transitioning);
+ animation: statusBlink 1s ease-in-out infinite;
+}
+
+.status[data-state='stopped'] .xdebugGlyph {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+/* Hovering the button crossfades the status dot into the action it triggers:
+ play when stopped, stop when running. The transitioning state keeps the
+ blinking dot — there's no action to take. */
+.status .actionGlyph.actionGlyph {
+ grid-row-start: 1;
+ grid-column-start: 1;
+ display: inline-grid;
+ place-items: center;
+ inline-size: 10px;
+ block-size: 10px;
+ width: 10px;
+ height: 10px;
+ color: var(--wpds-color-fg-content-neutral);
+ opacity: 0;
+ transition: opacity 100ms ease;
+}
+
+.status:not([data-state='transitioning']):is(:hover, :focus-visible) .glyph {
+ opacity: 0;
+}
+
+.status:not([data-state='transitioning']):is(:hover, :focus-visible) .actionGlyph {
+ opacity: 1;
+}
+
+.pauseMark,
+.pauseMark::before,
+.pauseMark::after {
+ display: block;
+ height: 8px;
+ border-radius: 1px;
+ background: currentColor;
+}
+
+.pauseMark {
+ position: relative;
+ width: 8px;
+ background: transparent;
+}
+
+.pauseMark::before,
+.pauseMark::after {
+ position: absolute;
+ top: 0;
+ width: 2px;
+ content: '';
+}
+
+.pauseMark::before {
+ left: 1px;
+}
+
+.pauseMark::after {
+ right: 1px;
+}
+
+.shape {
+ x: 0;
+ y: 0;
+ width: 8px;
+ height: 8px;
+ rx: 4px;
+ fill: var(--studio-color-status-running);
+ transition: fill 160ms ease, rx 160ms ease;
+}
+
+.playShape {
+ fill: var(--wpds-color-fg-content-neutral);
+}
+
+.status[data-state='running'] .shape {
+ fill: var(--studio-color-status-running);
+ rx: 4px;
+}
+
+.status[data-state='transitioning'] .shape {
+ fill: var(--studio-color-status-transitioning);
+ rx: 4px;
+ animation: statusBlink 1s ease-in-out infinite;
+}
+
+@keyframes statusBlink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.25;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .shape {
+ transition: none;
+ animation: none;
+ }
+
+ .glyph,
+ .actionGlyph {
+ transition: none;
+ }
+
+ .xdebugGlyph {
+ animation: none;
+ }
+}
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..32e6036fbf
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/index.tsx
@@ -0,0 +1,324 @@
+import { TRACKS_EVENTS } from '@studio/common/lib/record-tracks-event';
+import { useIsMutating } from '@tanstack/react-query';
+import { __ } from '@wordpress/i18n';
+import { arrowDown, arrowUp, external, Icon, moreVertical } from '@wordpress/icons';
+import { Button, Dialog, IconButton, Tooltip } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useEffect, useMemo, useRef, useState } from 'react';
+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 { SiteStatusButton } from '@/components/site-status-button';
+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 { useIsSiteStarting, useIsSiteStopping } from '@/data/queries/use-sites';
+import {
+ PULL_FROM_LIVE_MUTATION_KEY,
+ PUSH_TO_LIVE_MUTATION_KEY,
+ usePullSiteFromLive,
+ usePushSiteToLive,
+} from '@/data/queries/use-sync-site';
+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 styles from './style.module.css';
+import { ensureProtocol, pickLiveSite } 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 push / pull mutations for this site across hook instances.
+// They mutate the same local runtime, so a push started elsewhere (the publish
+// flow) must still read as busy here and block a concurrent pull that would
+// wedge the site.
+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;
+ return push || pull;
+}
+
+/**
+ * The site's permanent header: who you're working on and what state it's in 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.
+ *
+ * Pull and Push open the ported selective-sync dialog; Publish connects a
+ * WordPress.com site when none is. Preview sharing returns with the Share
+ * button in a follow-up.
+ */
+export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteToolbarProps ) {
+ const connector = useConnector();
+ const { enabled: agenticEnabled, reason: agenticReason } = useAgenticFeatures();
+ // The sidebar's site rows already carry a run-state dot for every site,
+ // including this one. A second one in the header only earns its place once
+ // the sidebar is out of view.
+ const showRunState = useSidebarCollapsed();
+ const login = useLogin();
+ const pushSiteToLive = usePushSiteToLive();
+ const pullSiteFromLive = usePullSiteFromLive();
+
+ const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null );
+ const [ publishOpen, setPublishOpen ] = useState( false );
+ const [ disconnectOpen, setDisconnectOpen ] = useState( false );
+
+ const isStarting = useIsSiteStarting( site.id );
+ const isStopping = useIsSiteStopping( site.id );
+ const isBusy = useIsSiteBusy( site.id );
+
+ const { data: connectedSites } = useConnectedWpcomSites( site.id );
+ const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] );
+
+ // 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 localSiteUrl = getSiteUrl( site );
+ const localSiteLabel = isStopping
+ ? __( 'Stopping…' )
+ : isStarting
+ ? __( 'Starting…' )
+ : getSiteDisplayUrl( site );
+ const canOpenLocalSite = site.running && ! isStopping;
+
+ return (
+
+
+
+
+ { site.name }
+
+ { showRunState ? (
+
+ ) : null }
+ { canOpenLocalSite ? (
+
+ {
+ void connector.trackEvent( TRACKS_EVENTS.SITE_OPEN_IN_BROWSER, {
+ browser: 'external',
+ } );
+ openExternal( localSiteUrl );
+ } }
+ >
+ { localSiteLabel }
+
+
+ }
+ />
+ }>
+ { __( 'Open Studio site in your browser' ) }
+
+
+ ) : (
+ { localSiteLabel }
+ ) }
+
+
+
+
+
+ { isSignedOut ? (
+
+ ) : liveSite ? (
+ <>
+
+ setSyncDialogType( 'pull' ) }
+ />
+ }
+ />
+ }>
+ { __( 'Pull from live' ) }
+
+
+
+ setSyncDialogType( 'push' ) }
+ />
+ }
+ />
+ }>
+ { __( 'Push to live' ) }
+
+
+
+
+ }
+ />
+
+ setDisconnectOpen( true ) }>
+ { __( 'Disconnect' ) }
+
+
+
+ >
+ ) : (
+
+ ) }
+
+
+ { liveSite && syncDialogType ? (
+
setSyncDialogType( null ) }
+ />
+ ) : null }
+
+ { liveSite ? (
+
+ ) : null }
+
+ { /* Mounted only while open: it loads the account's sites on mount. */ }
+ { publishOpen ? (
+
+
+ setPublishOpen( false ) } />
+
+
+ ) : null }
+
+ );
+}
diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.module.css b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css
similarity index 100%
rename from apps/ui/src/components/site-dropdown/publish-picker-view.module.css
rename to apps/ui/src/components/site-toolbar/publish-picker-view.module.css
diff --git a/apps/ui/src/components/site-dropdown/publish-picker-view.tsx b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx
similarity index 100%
rename from apps/ui/src/components/site-dropdown/publish-picker-view.tsx
rename to apps/ui/src/components/site-toolbar/publish-picker-view.tsx
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..09a8117755
--- /dev/null
+++ b/apps/ui/src/components/site-toolbar/style.module.css
@@ -0,0 +1,238 @@
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-gap-md);
+ padding-inline: var(--wpds-dimension-padding-lg);
+ /* 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;
+}
+
+.siteStatusRow {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+ line-height: 1.25;
+}
+
+/* Pull the status target back so its dot optically aligns with the site name
+ above rather than with the button's padded edge. Only when the button is
+ there — without it the URL would hang past the name. */
+.siteStatusRowWithButton {
+ margin-inline-start: -4px;
+}
+
+/* Doubled for specificity over the shared button's own 24px sizing: the
+ toolbar sits the control inside a line of text, where a 24px hit area
+ would overlap the site icon on hover. */
+.siteStatusButton.siteStatusButton {
+ flex-basis: 16px;
+ inline-size: 16px;
+ block-size: 16px;
+ -webkit-app-region: no-drag;
+}
+
+.siteUrl,
+.siteUrlStatic {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ font-size: var(--wpds-typography-font-size-xs);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.siteUrl {
+ -webkit-app-region: no-drag;
+ cursor: var(--wpds-cursor-control);
+ border-radius: 2px;
+ transition: color 100ms ease;
+}
+
+.siteUrl:hover {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.siteUrl:focus-visible {
+ outline: 2px solid var(--wpds-color-stroke-interactive-brand);
+ outline-offset: 2px;
+}
+
+.siteUrlIcon {
+ opacity: 0;
+ transition: opacity 100ms ease;
+}
+
+/* The icon SVGs carry no fill of their own, so they paint black in both colour
+ schemes unless told to follow the text. */
+.siteUrlIcon {
+ fill: currentColor;
+}
+
+.siteUrl:is(:hover, :focus-visible) .siteUrlIcon {
+ opacity: 1;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-gap-xs);
+ flex: 0 0 auto;
+ /* Pinned to the top of the header rather than centred against the site's
+ two-line identity block, and inset to line up with the preview pane's
+ toolbar buttons. Both panels start at the split frame's top edge; the
+ preview header pads 4px and then centres its controls in a 32px row. */
+ align-self: flex-start;
+ padding-block-start: var(--wpds-dimension-padding-md);
+ -webkit-app-region: no-drag;
+}
+
+/* --- 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;
+}
+
+.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,
+ .siteUrl,
+ .siteUrlIcon {
+ transition: none;
+ }
+
+ .actionLabel {
+ animation: none;
+ }
+}
+
diff --git a/apps/ui/src/components/site-dropdown/utils.ts b/apps/ui/src/components/site-toolbar/utils.ts
similarity index 59%
rename from apps/ui/src/components/site-dropdown/utils.ts
rename to apps/ui/src/components/site-toolbar/utils.ts
index 26d8f5e710..a72dc416b2 100644
--- a/apps/ui/src/components/site-dropdown/utils.ts
+++ b/apps/ui/src/components/site-toolbar/utils.ts
@@ -1,7 +1,5 @@
import { __ } from '@wordpress/i18n';
-import { getSiteDisplayUrl } from '@/lib/get-site-url';
-import type { SiteStatus } from './dropdown-trigger';
-import type { SiteDetails, Snapshot, SyncSite } from '@/data/core';
+import type { Snapshot, SyncSite } from '@/data/core';
export function stripProtocol( url: string ): string {
return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' );
@@ -11,6 +9,26 @@ export function ensureProtocol( url: string ): string {
return /^https?:\/\//.test( url ) ? url : `https://${ url }`;
}
+/**
+ * Connections in the order a picker should list them: production first, then
+ * staging, each group alphabetical so the list doesn't reshuffle between
+ * fetches.
+ */
+export function sortConnections( connectedSites: SyncSite[] | undefined ): SyncSite[] {
+ return [ ...( connectedSites ?? [] ) ].sort( ( a, b ) => {
+ if ( a.isStaging !== b.isStaging ) {
+ return a.isStaging ? 1 : -1;
+ }
+ return a.name.localeCompare( b.name );
+ } );
+}
+
+/** What to call a connection in a list where its sibling is right beside it. */
+export function getConnectionLabel( connectedSite: SyncSite ): string {
+ return connectedSite.isStaging ? __( 'Staging' ) : __( 'Production' );
+}
+
+/** The single connection the header's sync and disconnect actions target. */
export function pickLiveSite( connectedSites: SyncSite[] | undefined ): SyncSite | undefined {
if ( ! connectedSites || connectedSites.length === 0 ) {
return undefined;
@@ -45,33 +63,3 @@ export function pickLatestSnapshot(
export function getSnapshotHostname( snapshot: Snapshot ): string {
return stripProtocol( snapshot.url );
}
-
-// Derives the running/transitioning/stopped status plus the user-visible
-// labels for the local-site row. Collapses three related but noisy branches
-// into a single helper the dropdown can consume in one line.
-export function deriveSiteStatus(
- site: SiteDetails,
- isStarting: boolean,
- isStopping: boolean
-): { status: SiteStatus; statusLabel: string; localSublabel: string } {
- const status: SiteStatus =
- isStarting || isStopping ? 'transitioning' : site.running ? 'running' : 'stopped';
-
- const statusLabel =
- status === 'running'
- ? __( 'Site is running' )
- : status === 'transitioning'
- ? isStopping
- ? __( 'Site is stopping' )
- : __( 'Site is starting' )
- : __( 'Site is stopped' );
-
- const localSublabel =
- status === 'transitioning'
- ? isStopping
- ? __( 'Stopping…' )
- : __( 'Starting…' )
- : getSiteDisplayUrl( site );
-
- return { status, statusLabel, localSublabel };
-}
diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts
index 523388efa0..b9c79c9fc9 100644
--- a/apps/ui/src/data/queries/use-preview-site.ts
+++ b/apps/ui/src/data/queries/use-preview-site.ts
@@ -11,8 +11,8 @@ type PublishPreviewVariables = {
};
// 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();
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 952baa4aab..303052ea09 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 ? (
-
+
) : (
<>
diff --git a/apps/ui/src/ui-classic/router/route-site-overview/index.tsx b/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
index 546c5b387f..c1aa1231af 100644
--- a/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-site-overview/index.tsx
@@ -39,7 +39,7 @@ function SiteOverviewPage() {
{
void connector.trackEvent( TRACKS_EVENTS.PANEL_OPENED, {
panel: siteSettingsTabToPanel( next ),