diff --git a/apps/ui/src/components/settings-view/index.test.tsx b/apps/ui/src/components/settings-view/index.test.tsx
new file mode 100644
index 0000000000..fc0aacf4a9
--- /dev/null
+++ b/apps/ui/src/components/settings-view/index.test.tsx
@@ -0,0 +1,693 @@
+import '@testing-library/jest-dom/vitest';
+import { useBlocker } from '@tanstack/react-router';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useConnector } from '@/data/core';
+import { useAppGlobals } from '@/data/queries/use-app-globals';
+import { useAuthUser, useLogin, useLogout } from '@/data/queries/use-auth-user';
+import { useInstalledApps } from '@/data/queries/use-installed-apps';
+import {
+ useDeleteAllSnapshots,
+ useSnapshotUsage,
+ useSnapshots,
+} from '@/data/queries/use-snapshots';
+import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
+import {
+ useInstallAllWordPressSkills,
+ useInstallWordPressSkill,
+ useRemoveWordPressSkill,
+ useWordPressSkills,
+} from '@/data/queries/use-wordpress-skills';
+import { useOffline } from '@/hooks/use-offline';
+import { SettingsView, normalizeSettingsTab } from './index';
+import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
+
+vi.mock( '@tanstack/react-router', () => ( {
+ useBlocker: vi.fn(),
+} ) );
+
+vi.mock( '@wordpress/components', () => ( {
+ FormToggle: ( props: {
+ id?: string;
+ checked: boolean;
+ 'aria-label'?: string;
+ onChange: ( event: { target: { checked: boolean } } ) => void;
+ } ) => (
+ props.onChange( { target: { checked: event.target.checked } } ) }
+ />
+ ),
+} ) );
+
+vi.mock( '@wordpress/ui', () => ( {
+ Button: ( {
+ children,
+ loading,
+ loadingAnnouncement,
+ tone,
+ variant,
+ size,
+ ...props
+ }: ButtonHTMLAttributes< HTMLButtonElement > & {
+ children?: ReactNode;
+ loading?: boolean;
+ loadingAnnouncement?: string;
+ tone?: string;
+ variant?: string;
+ size?: string;
+ } ) => {
+ void tone;
+ void variant;
+ void size;
+ return ;
+ },
+ Icon: () => ,
+ IconButton: ( {
+ label,
+ icon,
+ tone,
+ variant,
+ size,
+ ...props
+ }: ButtonHTMLAttributes< HTMLButtonElement > & {
+ label: string;
+ icon?: unknown;
+ tone?: string;
+ variant?: string;
+ size?: string;
+ } ) => {
+ void icon;
+ void tone;
+ void variant;
+ void size;
+ return (
+
+ );
+ },
+ InputControl: ( {
+ label,
+ hideLabelFromVision,
+ suffix,
+ className,
+ ...props
+ }: InputHTMLAttributes< HTMLInputElement > & {
+ label: string;
+ hideLabelFromVision?: boolean;
+ suffix?: ReactNode;
+ } ) => (
+
+ ),
+ InputLayout: {
+ Slot: ( { children }: { children: ReactNode } ) => { children },
+ },
+ SelectControl: ( {
+ items = [],
+ label,
+ value,
+ onValueChange,
+ }: {
+ items?: Array< { label: string; value: string | null } >;
+ label: string;
+ value?: { label: string; value: string | null };
+ onValueChange?: ( item: { label: string; value: string | null } | undefined ) => void;
+ } ) => (
+
+ ),
+ Tooltip: {
+ Root: ( { children }: { children: ReactNode } ) => <>{ children }>,
+ Trigger: ( { render }: { render: ReactNode } ) => <>{ render }>,
+ Popup: ( { children }: { children: ReactNode } ) =>
{ children }
,
+ Positioner: () => null,
+ },
+} ) );
+
+vi.mock( '@/components/gravatar', () => ( {
+ Gravatar: ( { className }: { className?: string } ) => (
+
+ ),
+} ) );
+
+vi.mock( '@/components/learn-more', () => ( {
+ LearnMoreLink: () => Learn more,
+} ) );
+
+vi.mock( '@/components/menu', () => ( {
+ Root: ( { children }: { children: ReactNode } ) => { children }
,
+ Trigger: ( { render }: { render: ReactNode } ) => <>{ render }>,
+ Popup: ( { children }: { children: ReactNode } ) => { children }
,
+ Item: ( {
+ children,
+ disabled,
+ onClick,
+ }: {
+ children: ReactNode;
+ disabled?: boolean;
+ onClick?: () => void;
+ } ) => (
+
+ ),
+} ) );
+
+vi.mock( '@/components/tabs', () => ( {
+ Root: ( { children }: { children: ReactNode } ) => { children }
,
+ List: ( { children }: { children: ReactNode } ) => { children }
,
+ Tab: ( { children }: { children: ReactNode } ) => ,
+ Panel: ( { children }: { children: ReactNode } ) => { children }
,
+} ) );
+
+vi.mock( '@/data/core', () => ( {
+ useConnector: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-app-globals', () => ( {
+ useAppGlobals: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-auth-user', () => ( {
+ useAuthUser: vi.fn(),
+ useLogin: vi.fn(),
+ useLogout: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-installed-apps', () => ( {
+ useInstalledApps: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-snapshots', () => ( {
+ useDeleteAllSnapshots: vi.fn(),
+ useSnapshotUsage: vi.fn(),
+ useSnapshots: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-user-preferences', () => ( {
+ useSaveUserPreferences: vi.fn(),
+ useUserPreferences: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-wordpress-skills', () => ( {
+ useInstallAllWordPressSkills: vi.fn(),
+ useInstallWordPressSkill: vi.fn(),
+ useRemoveWordPressSkill: vi.fn(),
+ useWordPressSkills: vi.fn(),
+} ) );
+
+vi.mock( '@/hooks/use-fullscreen', () => ( {
+ useFullscreen: () => false,
+} ) );
+
+vi.mock( '@/hooks/use-offline', () => ( {
+ useOffline: vi.fn(),
+} ) );
+
+vi.mock( '@/hooks/use-prefers-color-scheme', () => ( {
+ usePrefersColorScheme: () => 'light',
+} ) );
+
+vi.mock( '@/hooks/use-sidebar-collapsed', () => ( {
+ useSidebarCollapsed: () => false,
+} ) );
+
+const useConnectorMock = vi.mocked( useConnector );
+const useAppGlobalsMock = vi.mocked( useAppGlobals );
+const useAuthUserMock = vi.mocked( useAuthUser );
+const useInstalledAppsMock = vi.mocked( useInstalledApps );
+const useLoginMock = vi.mocked( useLogin );
+const useLogoutMock = vi.mocked( useLogout );
+const useDeleteAllSnapshotsMock = vi.mocked( useDeleteAllSnapshots );
+const useSnapshotUsageMock = vi.mocked( useSnapshotUsage );
+const useSnapshotsMock = vi.mocked( useSnapshots );
+const useSaveUserPreferencesMock = vi.mocked( useSaveUserPreferences );
+const useUserPreferencesMock = vi.mocked( useUserPreferences );
+const useInstallAllWordPressSkillsMock = vi.mocked( useInstallAllWordPressSkills );
+const useInstallWordPressSkillMock = vi.mocked( useInstallWordPressSkill );
+const useRemoveWordPressSkillMock = vi.mocked( useRemoveWordPressSkill );
+const useWordPressSkillsMock = vi.mocked( useWordPressSkills );
+const useOfflineMock = vi.mocked( useOffline );
+const useBlockerMock = vi.mocked( useBlocker );
+
+describe( 'SettingsView', () => {
+ const mutate = vi.fn();
+ const loginMutate = vi.fn();
+ const logoutMutate = vi.fn();
+ const deleteSnapshotsMutate = vi.fn();
+ const installSkillMutate = vi.fn();
+ const installSkillMutateAsync = vi.fn();
+ const installAllSkillsMutate = vi.fn();
+ const removeSkillMutate = vi.fn();
+ const selectDefaultSiteDirectory = vi.fn();
+ const confirmDeleteAllPreviewSites = vi.fn();
+ const copyText = vi.fn();
+ const openExternalUrl = vi.fn();
+
+ beforeEach( () => {
+ vi.clearAllMocks();
+
+ selectDefaultSiteDirectory.mockResolvedValue( '/Users/example/Sites' );
+ confirmDeleteAllPreviewSites.mockResolvedValue( true );
+ copyText.mockResolvedValue( undefined );
+ installSkillMutateAsync.mockResolvedValue( undefined );
+ useBlockerMock.mockReturnValue( undefined as never );
+
+ useConnectorMock.mockReturnValue( {
+ previewColorScheme: vi.fn(),
+ selectDefaultSiteDirectory,
+ confirmDeleteAllPreviewSites,
+ copyText,
+ openExternalUrl,
+ } as never );
+ useAppGlobalsMock.mockReturnValue( {
+ data: { isWindowsStore: false, platform: 'darwin' },
+ } as never );
+ useOfflineMock.mockReturnValue( false );
+ useAuthUserMock.mockReturnValue( {
+ data: {
+ id: 1,
+ displayName: 'Ada Lovelace',
+ email: 'ada@example.com',
+ },
+ isLoading: false,
+ } as never );
+ useLoginMock.mockReturnValue( { mutate: loginMutate, isPending: false } as never );
+ useLogoutMock.mockReturnValue( { mutate: logoutMutate, isPending: false } as never );
+ useInstalledAppsMock.mockReturnValue( { data: {} } as never );
+ useSaveUserPreferencesMock.mockReturnValue( {
+ mutate,
+ isPending: false,
+ } as never );
+ useUserPreferencesMock.mockReturnValue( {
+ data: {
+ editor: null,
+ terminal: 'terminal',
+ colorScheme: 'system',
+ locale: 'en',
+ defaultSiteDirectory: '/Users/example/Studio',
+ studioCliInstalled: false,
+ },
+ isLoading: false,
+ } as never );
+ useSnapshotsMock.mockReturnValue( { data: [], isLoading: false } as never );
+ useSnapshotUsageMock.mockReturnValue( {
+ data: { siteCount: 2, siteLimit: 10, siteCreationBlocked: false },
+ isLoading: false,
+ } as never );
+ useDeleteAllSnapshotsMock.mockReturnValue( {
+ mutate: deleteSnapshotsMutate,
+ isPending: false,
+ error: null,
+ } as never );
+ useWordPressSkillsMock.mockReturnValue( {
+ data: [
+ {
+ id: 'studio-cli',
+ displayName: 'Studio CLI',
+ description: 'Use Studio from the terminal.',
+ installed: true,
+ },
+ {
+ id: 'wp-rest-api',
+ displayName: 'WP REST API',
+ description: 'Work with the WordPress REST API.',
+ installed: false,
+ },
+ ],
+ isLoading: false,
+ error: null,
+ } as never );
+ useInstallWordPressSkillMock.mockReturnValue( {
+ mutate: installSkillMutate,
+ mutateAsync: installSkillMutateAsync,
+ isPending: false,
+ error: null,
+ variables: undefined,
+ } as never );
+ useInstallAllWordPressSkillsMock.mockReturnValue( {
+ mutate: installAllSkillsMutate,
+ isPending: false,
+ error: null,
+ variables: undefined,
+ } as never );
+ useRemoveWordPressSkillMock.mockReturnValue( {
+ mutate: removeSkillMutate,
+ isPending: false,
+ error: null,
+ variables: undefined,
+ } as never );
+ } );
+
+ it( 'saves default site directory and Studio CLI changes through the preferences patch', async () => {
+ render( );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Studio. Choose a different folder.',
+ } )
+ );
+ fireEvent.click( screen.getByLabelText( 'Studio CLI for terminal' ) );
+
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Sites. Choose a different folder.',
+ } )
+ ).toHaveTextContent( '/Users/example/Sites' )
+ );
+
+ const saveButton = screen.getByRole( 'button', { name: 'Save' } );
+ expect( saveButton ).toHaveAttribute( 'aria-keyshortcuts', expect.stringMatching( /\+S$/ ) );
+ expect( saveButton ).toHaveTextContent( /^Save$/ );
+ expect( screen.getByText( /Save settings \((⌘|Ctrl)\+S\)/ ) ).toBeInTheDocument();
+
+ fireEvent.click( saveButton );
+
+ expect( mutate ).toHaveBeenCalledWith(
+ {
+ defaultSiteDirectory: '/Users/example/Sites',
+ studioCliInstalled: true,
+ },
+ expect.any( Object )
+ );
+ } );
+
+ it( 'saves preferences with the primary modifier S shortcut', async () => {
+ render( );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Studio. Choose a different folder.',
+ } )
+ );
+
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Sites. Choose a different folder.',
+ } )
+ ).toHaveTextContent( '/Users/example/Sites' )
+ );
+
+ fireEvent.keyDown( window, { key: 's', metaKey: true } );
+
+ expect( mutate ).toHaveBeenCalledWith(
+ {
+ defaultSiteDirectory: '/Users/example/Sites',
+ },
+ expect.any( Object )
+ );
+ } );
+
+ it( 'shows a loading save button without disabled styling while saving', async () => {
+ useSaveUserPreferencesMock.mockReturnValue( {
+ mutate,
+ isPending: true,
+ } as never );
+
+ render( );
+
+ fireEvent.click(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Studio. Choose a different folder.',
+ } )
+ );
+
+ await waitFor( () =>
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Default site directory: /Users/example/Sites. Choose a different folder.',
+ } )
+ ).toHaveTextContent( '/Users/example/Sites' )
+ );
+
+ const saveButton = screen.getByRole( 'button', { name: 'Saving settings' } );
+ expect( saveButton ).toBeEnabled();
+ } );
+
+ it( 'shows a save error when preferences fail to persist', async () => {
+ mutate.mockImplementationOnce( ( _patch, options ) => {
+ options.onError( new Error( 'Unable to install Studio CLI.' ) );
+ } );
+
+ render( );
+
+ fireEvent.click( screen.getByLabelText( 'Studio CLI for terminal' ) );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Save' } ) );
+
+ expect( screen.getByText( 'Unable to install Studio CLI.' ) ).toBeInTheDocument();
+ } );
+
+ it( 'confirms before navigating away with unsaved preferences', async () => {
+ render( );
+
+ fireEvent.click( screen.getByLabelText( 'Studio CLI for terminal' ) );
+
+ await waitFor( () =>
+ expect(
+ ( useBlockerMock.mock.calls.at( -1 )?.[ 0 ] as { disabled?: boolean } ).disabled
+ ).toBe( false )
+ );
+
+ const blocker = useBlockerMock.mock.calls.at( -1 )?.[ 0 ] as unknown as {
+ shouldBlockFn: ( args: unknown ) => boolean | Promise< boolean >;
+ };
+ const confirmSpy = vi.spyOn( window, 'confirm' );
+
+ confirmSpy.mockReturnValueOnce( false );
+ await expect(
+ Promise.resolve(
+ blocker.shouldBlockFn( {
+ current: { pathname: '/settings' },
+ next: { pathname: '/sessions/session-1' },
+ action: 'PUSH',
+ } )
+ )
+ ).resolves.toBe( true );
+ expect( confirmSpy ).toHaveBeenCalledWith(
+ 'You have unsaved settings. If you leave this screen, your changes will be lost.'
+ );
+
+ confirmSpy.mockReturnValueOnce( true );
+ await expect(
+ Promise.resolve(
+ blocker.shouldBlockFn( {
+ current: { pathname: '/settings' },
+ next: { pathname: '/sessions/session-1' },
+ action: 'PUSH',
+ } )
+ )
+ ).resolves.toBe( false );
+
+ confirmSpy.mockClear();
+ await expect(
+ Promise.resolve(
+ blocker.shouldBlockFn( {
+ current: { pathname: '/settings' },
+ next: { pathname: '/settings' },
+ action: 'PUSH',
+ } )
+ )
+ ).resolves.toBe( false );
+ expect( confirmSpy ).not.toHaveBeenCalled();
+
+ confirmSpy.mockRestore();
+ } );
+
+ it( 'renders usage and confirms preview-site deletion through the connector', async () => {
+ const windowConfirmSpy = vi.spyOn( window, 'confirm' );
+
+ render( );
+
+ expect( screen.getByRole( 'heading', { name: 'Usage' } ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeInTheDocument();
+ expect( screen.getByText( 'AI credits' ) ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
+ )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Unlimited in beta' ) ).not.toBeInTheDocument();
+ expect( screen.getByText( '2 of 10 active preview sites' ) ).toBeInTheDocument();
+ expect( useSnapshotsMock ).toHaveBeenCalledWith( 1 );
+ expect( useSnapshotUsageMock ).toHaveBeenCalledWith( 1 );
+ expect( useDeleteAllSnapshotsMock ).toHaveBeenCalledWith( 1 );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Delete all preview sites' } ) );
+
+ await waitFor( () => expect( confirmDeleteAllPreviewSites ).toHaveBeenCalledTimes( 1 ) );
+ expect( deleteSnapshotsMutate ).toHaveBeenCalledTimes( 1 );
+ expect( windowConfirmSpy ).not.toHaveBeenCalled();
+ windowConfirmSpy.mockRestore();
+ } );
+
+ it( 'disables preview-site deletion while offline', () => {
+ useOfflineMock.mockReturnValue( true );
+
+ render( );
+
+ const deleteAction = screen.getByRole( 'button', {
+ name: 'Deleting preview sites requires an internet connection.',
+ } );
+
+ expect( deleteAction ).toBeDisabled();
+ fireEvent.click( deleteAction );
+
+ expect( confirmDeleteAllPreviewSites ).not.toHaveBeenCalled();
+ expect( deleteSnapshotsMutate ).not.toHaveBeenCalled();
+ } );
+
+ it( 'hides native-only preferences in browser-hosted mode', () => {
+ useAppGlobalsMock.mockReturnValue( {
+ data: { isWindowsStore: false, platform: 'browser' },
+ } as never );
+
+ render( );
+
+ expect( screen.queryByText( 'Preferred editor' ) ).not.toBeInTheDocument();
+ expect( screen.queryByText( 'Preferred terminal' ) ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', {
+ name: 'Default site directory: /Users/example/Studio. Choose a different folder.',
+ } )
+ ).not.toBeInTheDocument();
+ expect( screen.queryByLabelText( 'Studio CLI for terminal' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'waits for app globals before showing native-only preferences', () => {
+ useAppGlobalsMock.mockReturnValue( {
+ data: undefined,
+ isLoading: true,
+ } as never );
+
+ render( );
+
+ expect( screen.getByText( 'Loading...' ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'Preferred editor' ) ).not.toBeInTheDocument();
+ expect( screen.queryByLabelText( 'Studio CLI for terminal' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders keyboard shortcuts', () => {
+ render( );
+
+ expect( screen.getByRole( 'button', { name: 'Keyboard' } ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'heading', { name: 'Keyboard shortcuts' } ) ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Use these keyboard shortcuts to move faster around Studio.' )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Add site' ) ).not.toBeInTheDocument();
+ expect( screen.getByText( 'Send message' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Insert newline' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Toggle site preview' ) ).toBeInTheDocument();
+ expect( normalizeSettingsTab( 'keyboard' ) ).toBe( 'keyboard' );
+ } );
+
+ it( 'opens account help actions through buttons from preferences', () => {
+ render( );
+
+ expect( screen.getByText( 'Ada Lovelace' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'ada@example.com' ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'heading', { name: 'Account' } ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'Edit WordPress.com profile' ) ).not.toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Docs' } ) );
+
+ expect( openExternalUrl ).toHaveBeenCalledWith(
+ 'https://developer.wordpress.com/docs/developer-tools/studio/'
+ );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Report an issue' } ) );
+
+ expect( openExternalUrl ).toHaveBeenCalledWith(
+ 'https://github.com/Automattic/studio/issues/new/choose'
+ );
+ } );
+
+ it( 'lets signed-out users log in from preferences', () => {
+ useAuthUserMock.mockReturnValue( { data: null, isLoading: false } as never );
+
+ render( );
+
+ expect(
+ screen.getByText( 'Log in to use AI features and synchronize with live and preview sites.' )
+ ).toBeInTheDocument();
+ expect( screen.queryByTestId( 'gravatar' ) ).not.toBeInTheDocument();
+
+ fireEvent.click( screen.getAllByRole( 'button', { name: 'Log in' } )[ 0 ] );
+
+ expect( loginMutate ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'manages global WordPress skills', async () => {
+ render( );
+
+ expect( screen.getByRole( 'heading', { name: 'Skills' } ) ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Skills are reusable instructions that teach agents how to complete specialized WordPress tasks/
+ )
+ ).toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Install' } ) );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Remove' } ) );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Install all' } ) );
+
+ expect( installSkillMutate ).toHaveBeenCalledWith( 'wp-rest-api' );
+ expect( removeSkillMutate ).toHaveBeenCalledWith( 'studio-cli' );
+ expect( installAllSkillsMutate ).toHaveBeenCalledWith( [ 'wp-rest-api' ] );
+ } );
+
+ it( 'copies the MCP configuration through the connector', async () => {
+ render( );
+
+ expect( screen.getByRole( 'heading', { name: 'MCP' } ) ).toBeInTheDocument();
+ expect( screen.getByText( /MCP lets other AI tools talk to Studio/ ) ).toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Copy MCP configuration' } ) );
+
+ await waitFor( () => expect( copyText ).toHaveBeenCalledTimes( 1 ) );
+ expect( copyText.mock.calls[ 0 ][ 0 ] ).toContain( 'wordpress-studio' );
+ await waitFor( () => expect( screen.getAllByText( 'Copied' ).length ).toBeGreaterThan( 0 ) );
+ } );
+
+ it( 'does not show copied feedback when MCP configuration copy fails', async () => {
+ const error = new Error( 'Clipboard unavailable' );
+ const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => undefined );
+ copyText.mockRejectedValueOnce( error );
+
+ try {
+ render( );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Copy MCP configuration' } ) );
+
+ await waitFor( () =>
+ expect( consoleError ).toHaveBeenCalledWith( 'Failed to copy MCP configuration:', error )
+ );
+ expect( screen.queryByText( 'Copied' ) ).not.toBeInTheDocument();
+ } finally {
+ consoleError.mockRestore();
+ }
+ } );
+} );
diff --git a/apps/ui/src/components/settings-view/index.tsx b/apps/ui/src/components/settings-view/index.tsx
index 359e8d4a7a..6ef86bd009 100644
--- a/apps/ui/src/components/settings-view/index.tsx
+++ b/apps/ui/src/components/settings-view/index.tsx
@@ -1,77 +1,76 @@
-import { isSupportedLocale, supportedLocaleNames } from '@studio/common/lib/locale';
+import { supportedLocaleNames } from '@studio/common/lib/locale';
+import { getMcpServerConfigJson } from '@studio/common/lib/mcp-config';
import { SUPPORTED_EDITORS, supportedEditorConfig } from '@studio/common/lib/user-settings/editor';
import { SUPPORTED_TERMINALS, terminalConfig } from '@studio/common/lib/user-settings/terminal';
-import { DataForm } from '@wordpress/dataviews';
-import { __ } from '@wordpress/i18n';
-import { Button } from '@wordpress/ui';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useBlocker } from '@tanstack/react-router';
+import { FormToggle } from '@wordpress/components';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { check, copy, file, Icon, moreHorizontal } from '@wordpress/icons';
+import { Button, IconButton, SelectControl, Tooltip } from '@wordpress/ui';
+import { clsx } from 'clsx';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Gravatar } from '@/components/gravatar';
+import { LearnMoreLink } from '@/components/learn-more';
+import * as Menu from '@/components/menu';
import * as Tabs from '@/components/tabs';
+import { useConnector } from '@/data/core';
import { persister } from '@/data/core/query-client';
+import { useAppGlobals } from '@/data/queries/use-app-globals';
+import { useAuthUser, useLogin, useLogout } from '@/data/queries/use-auth-user';
import { useInstalledApps } from '@/data/queries/use-installed-apps';
+import {
+ useDeleteAllSnapshots,
+ useSnapshotUsage,
+ useSnapshots,
+} from '@/data/queries/use-snapshots';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
+import {
+ useInstallAllWordPressSkills,
+ useInstallWordPressSkill,
+ useRemoveWordPressSkill,
+ useWordPressSkills,
+} from '@/data/queries/use-wordpress-skills';
import { useFullscreen } from '@/hooks/use-fullscreen';
+import { useOffline } from '@/hooks/use-offline';
+import { usePrefersColorScheme } from '@/hooks/use-prefers-color-scheme';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
+import {
+ UNSET,
+ diffPreferencesFromSaved,
+ toPreferencesFormData,
+ type PreferencesFormData,
+} from './preferences';
import styles from './style.module.css';
import type {
ColorScheme,
InstalledApps,
+ SkillStatus,
SupportedEditor,
SupportedLocale,
SupportedTerminal,
- UserPreferences,
- WritableUserPreferences,
} from '@/data/core';
-import type { Field, Form } from '@wordpress/dataviews';
-import type { FormEvent } from 'react';
+import type { FormEvent, KeyboardEvent, ReactNode } from 'react';
-type TabId = 'preferences';
+const SETTINGS_TABS = [ 'preferences', 'usage', 'keyboard', 'skills', 'mcp' ] as const;
-export function isSettingsTab( value: string ): value is TabId {
- return value === 'preferences';
-}
-
-export type SettingsTabId = TabId;
+type TabId = ( typeof SETTINGS_TABS )[ number ];
-// Empty-string sentinel for "not set" — DataForm's select-style fields need a
-// primitive value, so we can't use null directly.
-const UNSET = '' as const;
-
-interface FormData {
- editor: SupportedEditor | typeof UNSET;
- terminal: SupportedTerminal | typeof UNSET;
- colorScheme: ColorScheme;
- locale: SupportedLocale;
+export function isSettingsTab( value: string ): value is TabId {
+ return SETTINGS_TABS.includes( value as TabId );
}
-// The saved locale can be any string the main process resolved (including ones
-// outside our catalog). Clamp to a SupportedLocale so the form control always
-// has a valid option selected.
-function resolveFormLocale( locale: string | undefined ): SupportedLocale {
- return isSupportedLocale( locale ) ? locale : 'en';
+export function normalizeSettingsTab( value: string | undefined ): TabId {
+ if ( value && isSettingsTab( value ) ) {
+ return value;
+ }
+ return 'preferences';
}
-function toFormData( prefs: UserPreferences ): FormData {
- return {
- editor: prefs.editor ?? UNSET,
- terminal: prefs.terminal ?? UNSET,
- colorScheme: prefs.colorScheme,
- locale: resolveFormLocale( prefs.locale ),
- };
-}
+export type SettingsTabId = TabId;
-function diffFromSaved(
- next: FormData,
- saved: UserPreferences
-): Partial< WritableUserPreferences > {
- const patch: Partial< WritableUserPreferences > = {};
- const nextEditor: SupportedEditor | null = next.editor === UNSET ? null : next.editor;
- const nextTerminal: SupportedTerminal | null = next.terminal === UNSET ? null : next.terminal;
- if ( nextEditor !== saved.editor ) patch.editor = nextEditor;
- if ( nextTerminal !== saved.terminal ) patch.terminal = nextTerminal;
- if ( next.colorScheme !== saved.colorScheme ) patch.colorScheme = next.colorScheme;
- if ( next.locale !== resolveFormLocale( saved.locale ) ) patch.locale = next.locale;
- return patch;
-}
+const DEFAULT_PREVIEW_SITE_LIMIT = 10;
+const DOCS_URL = 'https://developer.wordpress.com/docs/developer-tools/studio/';
+const REPORT_ISSUE_URL = 'https://github.com/Automattic/studio/issues/new/choose';
function editorElements( installedApps: InstalledApps | undefined ) {
const options = SUPPORTED_EDITORS.filter(
@@ -93,17 +92,31 @@ function terminalElements( installedApps: InstalledApps | undefined ) {
return [ { value: UNSET, label: __( 'Not set' ) }, ...options ];
}
-const COLOR_SCHEME_ELEMENTS: { value: ColorScheme; label: string }[] = [
- { value: 'system', label: __( 'System' ) },
- { value: 'light', label: __( 'Light' ) },
- { value: 'dark', label: __( 'Dark' ) },
-];
+function colorSchemeElements(): { value: ColorScheme; label: string }[] {
+ return [
+ { value: 'system', label: __( 'System' ) },
+ { value: 'light', label: __( 'Light' ) },
+ { value: 'dark', label: __( 'Dark' ) },
+ ];
+}
+
+function isColorScheme( value: unknown ): value is ColorScheme {
+ return value === 'system' || value === 'light' || value === 'dark';
+}
const LOCALE_ELEMENTS: { value: SupportedLocale; label: string }[] = Object.entries(
supportedLocaleNames
).map( ( [ value, label ] ) => ( { value: value as SupportedLocale, label } ) );
-function SettingsHeader() {
+function SettingsHeader( {
+ canSubmit,
+ isSaving,
+ onSave,
+}: {
+ canSubmit: boolean;
+ isSaving: boolean;
+ onSave: () => void;
+} ) {
const sidebarCollapsed = useSidebarCollapsed();
const isFullscreen = useFullscreen();
const toggleSpacerClass = sidebarCollapsed
@@ -111,9 +124,861 @@ function SettingsHeader() {
? styles.toggleSpacerFullscreen
: styles.toggleSpacer
: null;
+ const saveShortcutModifier = getPlatformModifierKeyLabel() === '⌘' ? '⌘' : 'Ctrl';
+ const saveShortcutTooltip = sprintf( __( 'Save settings (%s+S)' ), saveShortcutModifier );
+ const saveShortcutAria = saveShortcutModifier === '⌘' ? 'Meta+S' : 'Control+S';
+
return (
- { toggleSpacerClass ?
: null }
+ { toggleSpacerClass ? (
+
+
+
+ ) : null }
+
+
+ { __( 'Settings' ) }
+ { __( 'Usage' ) }
+ { __( 'Keyboard' ) }
+ { __( 'Skills' ) }
+ { __( 'MCP' ) }
+
+
+
+
+
+
+
+ }
+ />
+ }>
+ { saveShortcutTooltip }
+
+
+
+
+ );
+}
+
+function PreferenceRow( {
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description?: ReactNode;
+ children: ReactNode;
+} ) {
+ return (
+
+
+
{ title }
+ { description ?
{ description }
: null }
+
+ { children }
+
+ );
+}
+
+function AppearancePicker( {
+ value,
+ onChange,
+}: {
+ value: ColorScheme;
+ onChange: ( value: ColorScheme ) => void;
+} ) {
+ const options = colorSchemeElements();
+ const activeIndex = Math.max(
+ 0,
+ options.findIndex( ( option ) => option.value === value )
+ );
+
+ return (
+
+
+ { options.map( ( option ) => (
+
+ ) ) }
+
+
+ );
+}
+
+function PreferenceSelect< TValue extends string >( {
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: TValue;
+ options: Array< { value: TValue; label: string } >;
+ onChange: ( value: TValue ) => void;
+} ) {
+ const selectedItem = options.find( ( option ) => option.value === value );
+
+ return (
+ {
+ if ( item?.value !== undefined && item.value !== null ) {
+ onChange( item.value as TValue );
+ }
+ } }
+ />
+ );
+}
+
+function DefaultSiteDirectoryField( { value, onSelect }: { value: string; onSelect: () => void } ) {
+ const chooseLabel = value
+ ? sprintf( __( 'Default site directory: %s. Choose a different folder.' ), value )
+ : __( 'Default site directory: Choose a folder.' );
+ const placeholder = __( 'Choose a folder...' );
+
+ const handleKeyDown = ( event: KeyboardEvent< HTMLButtonElement > ) => {
+ if ( event.key === 'Enter' || event.key === ' ' ) {
+ event.preventDefault();
+ onSelect();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+function StudioCliSection( {
+ checked,
+ onChange,
+}: {
+ checked: boolean;
+ onChange: ( checked: boolean ) => void;
+} ) {
+ return (
+
+
+
+ { __( 'Studio CLI' ) }
+
+ onChange( event.target.checked ) }
+ />
+
+
+ { __( 'Use the studio command in any terminal to manage sites and run WP-CLI.' ) }{ ' ' }
+
+
+
+ );
+}
+
+function AccountInformationSection() {
+ const { data: user, isLoading } = useAuthUser();
+ const { data: preferences } = useUserPreferences();
+ const login = useLogin();
+ const logout = useLogout();
+ const effectiveScheme = usePrefersColorScheme();
+ const savedScheme = preferences?.colorScheme;
+ const themeIsDark =
+ savedScheme === 'dark' || ( savedScheme !== 'light' && effectiveScheme === 'dark' );
+
+ return (
+
+
+
+ { __( 'Account' ) }
+
+
+
+
+
+ { user ? (
+
+ ) : null }
+
+
{ user ? user.displayName : __( 'WordPress.com account' ) }
+
+ { user
+ ? user.email
+ : __( 'Log in to use AI features and synchronize with live and preview sites.' ) }
+
+
+
+ { user ? (
+
+ ) : (
+
+ ) }
+
+
+ );
+}
+
+function AccountHelpActions() {
+ const connector = useConnector();
+
+ const openLink = ( url: string ) => {
+ void connector.openExternalUrl( url );
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+function PreferencesPanel( {
+ data,
+ installedApps,
+ showStudioCliToggle,
+ showNativePreferences,
+ onColorSchemeChange,
+ onDefaultSiteDirectorySelect,
+ onChange,
+}: {
+ data: PreferencesFormData;
+ installedApps: InstalledApps | undefined;
+ showStudioCliToggle: boolean;
+ showNativePreferences: boolean;
+ onColorSchemeChange: ( value: ColorScheme ) => void;
+ onDefaultSiteDirectorySelect: () => void;
+ onChange: ( update: Partial< PreferencesFormData > ) => void;
+} ) {
+ return (
+
+
+ { __( 'General' ) }
+
+
+ onChange( { locale } ) }
+ />
+
+ { showNativePreferences ? (
+ <>
+
+
+ label={ __( 'Preferred editor' ) }
+ value={ data.editor }
+ options={ editorElements( installedApps ) }
+ onChange={ ( editor ) => onChange( { editor } ) }
+ />
+
+
+
+ label={ __( 'Preferred terminal' ) }
+ value={ data.terminal }
+ options={ terminalElements( installedApps ) }
+ onChange={ ( terminal ) => onChange( { terminal } ) }
+ />
+
+
+ >
+ ) : null }
+
+
+ { showStudioCliToggle ? (
+
onChange( { studioCliInstalled } ) }
+ />
+ ) : null }
+
+ );
+}
+
+function PreviewSitesSummary( { userId }: { userId: number } ) {
+ const connector = useConnector();
+ const isOffline = useOffline();
+ const { data: snapshots, isLoading } = useSnapshots( userId );
+ const { data: snapshotUsage, isLoading: isLoadingSnapshotUsage } = useSnapshotUsage( userId );
+ const deleteAllSnapshots = useDeleteAllSnapshots( userId );
+ const siteCount = snapshotUsage?.siteCount ?? snapshots?.length ?? 0;
+ const siteLimit = snapshotUsage?.siteLimit ?? DEFAULT_PREVIEW_SITE_LIMIT;
+ const progressMax = Math.max( siteLimit, 1 );
+ const snapshotCreationBlocked = snapshotUsage?.siteCreationBlocked ?? false;
+ const isLoadingPreviewUsage = isLoading || isLoadingSnapshotUsage || deleteAllSnapshots.isPending;
+ const isDisabled =
+ siteCount === 0 || snapshotCreationBlocked || isLoadingPreviewUsage || isOffline;
+ const progress = Math.min( siteCount / progressMax, 1 ) * 100;
+ const deletePreviewSitesLabel = isOffline
+ ? __( 'Deleting preview sites requires an internet connection.' )
+ : deleteAllSnapshots.isPending
+ ? __( 'Deleting preview sites...' )
+ : __( 'Delete all preview sites' );
+
+ const handleDelete = async () => {
+ if ( isDisabled ) {
+ return;
+ }
+ const confirmed = await connector.confirmDeleteAllPreviewSites();
+ if ( confirmed ) {
+ deleteAllSnapshots.mutate();
+ }
+ };
+
+ return (
+
+
+
{ __( 'Preview sites' ) }
+ { ! snapshotCreationBlocked ? (
+
+
+ }
+ />
+
+ void handleDelete() }>
+ { deletePreviewSitesLabel }
+
+
+
+ ) : null }
+
+ { snapshotCreationBlocked ? (
+
+ { __( 'Preview sites are not available for your account.' ) }
+
+ ) : (
+ <>
+
+ { isLoadingPreviewUsage
+ ? __( 'Loading...' )
+ : sprintf(
+ /* translators: 1: number of active preview sites, 2: maximum allowed */
+ _n(
+ '%1$d of %2$d active preview site',
+ '%1$d of %2$d active preview sites',
+ siteCount
+ ),
+ siteCount,
+ siteLimit
+ ) }
+
+
+ >
+ ) }
+ { deleteAllSnapshots.error ? (
+
+ { __( 'An error occurred while deleting preview sites. Please try again.' ) }
+
+ ) : null }
+
+ );
+}
+
+function UsageSettingsPanel() {
+ const { data: user, isLoading } = useAuthUser();
+ const login = useLogin();
+
+ return (
+
+
+
+
{ __( 'Usage' ) }
+
{ __( 'Track your preview site usage and Studio Code AI credits.' ) }
+
+
+
+
{ __( 'AI credits' ) }
+
+
+ { __(
+ 'AI credits are currently free while Studio Code is in Alpha. Build, iterate, and experiment, but know that credits will eventually have a cost.'
+ ) }
+
+
+
+ { user ? (
+
+ ) : (
+
+
+
{ __( 'Preview sites' ) }
+
+
+ { isLoading
+ ? __( 'Loading...' )
+ : __( 'Log in to view preview site usage for your account.' ) }
+
+ { ! isLoading ? (
+
+ ) : null }
+
+ ) }
+
+
+ );
+}
+
+function getErrorMessage( error: unknown ): string | null {
+ return error instanceof Error ? error.message : error ? String( error ) : null;
+}
+
+function getPlatformModifierKeyLabel(): string {
+ if ( typeof navigator === 'undefined' ) {
+ return 'Ctrl';
+ }
+ return /mac|iphone|ipad|ipod/i.test( navigator.platform || navigator.userAgent ) ? '⌘' : 'Ctrl';
+}
+
+function getShortcutKeyAriaLabel( key: string ): string {
+ switch ( key ) {
+ case '⌘':
+ return __( 'Command' );
+ case '↩':
+ return __( 'Return' );
+ case ',':
+ return __( 'Comma' );
+ case '[':
+ return __( 'Left bracket' );
+ case ']':
+ return __( 'Right bracket' );
+ default:
+ return key;
+ }
+}
+
+type KeyboardShortcut = {
+ label: string;
+ keys: string[];
+};
+
+type KeyboardShortcutSection = {
+ title: string;
+ shortcuts: KeyboardShortcut[];
+};
+
+function getKeyboardShortcutSections( modifierKey: string ): KeyboardShortcutSection[] {
+ return [
+ {
+ title: __( 'General' ),
+ shortcuts: [ { label: __( 'Open settings' ), keys: [ modifierKey, ',' ] } ],
+ },
+ {
+ title: __( 'Composer' ),
+ shortcuts: [
+ { label: __( 'Send message' ), keys: [ modifierKey, '↩' ] },
+ { label: __( 'Insert newline' ), keys: [ 'Shift', '↩' ] },
+ { label: __( 'Stop response' ), keys: [ 'Esc' ] },
+ ],
+ },
+ {
+ title: __( 'Site preview' ),
+ shortcuts: [
+ { label: __( 'Toggle site preview' ), keys: [ modifierKey, 'Shift', 'B' ] },
+ { label: __( 'Reload preview' ), keys: [ modifierKey, 'R' ] },
+ { label: __( 'Go back in preview' ), keys: [ modifierKey, '[' ] },
+ { label: __( 'Go forward in preview' ), keys: [ modifierKey, ']' ] },
+ ],
+ },
+ ];
+}
+
+function ShortcutKeys( { keys }: { keys: string[] } ) {
+ return (
+
+ { keys.map( ( key, index ) => (
+
+ { key }
+
+ ) ) }
+
+ );
+}
+
+function KeyboardShortcutGroup( { title, shortcuts }: KeyboardShortcutSection ) {
+ return (
+
+
+
{ title }
+
+
+ { shortcuts.map( ( shortcut ) => (
+ -
+ { shortcut.label }
+
+
+ ) ) }
+
+
+ );
+}
+
+function KeyboardSettingsPanel() {
+ const shortcutSections = getKeyboardShortcutSections( getPlatformModifierKeyLabel() );
+
+ return (
+
+
+
+
{ __( 'Keyboard shortcuts' ) }
+
{ __( 'Use these keyboard shortcuts to move faster around Studio.' ) }
+
+ { shortcutSections.map( ( section ) => (
+
+ ) ) }
+
+
+ );
+}
+
+function SkillRow( {
+ skill,
+ actionLabel,
+ busy,
+ disabled,
+ onAction,
+}: {
+ skill: SkillStatus;
+ actionLabel: string;
+ busy: boolean;
+ disabled: boolean;
+ onAction: () => void;
+} ) {
+ return (
+
+
+ { skill.displayName }
+ { skill.description }
+
+
+
+ );
+}
+
+function SkillSection( {
+ title,
+ headerAction,
+ children,
+}: {
+ title: string;
+ headerAction?: ReactNode;
+ children: ReactNode;
+} ) {
+ return (
+
+
+
{ title }
+ { headerAction ? (
+
{ headerAction }
+ ) : null }
+
+
+
+ );
+}
+
+function SkillsSettingsPanel() {
+ const { data: skills, isLoading, error } = useWordPressSkills();
+ const installSkill = useInstallWordPressSkill();
+ const installAllSkills = useInstallAllWordPressSkills();
+ const removeSkill = useRemoveWordPressSkill();
+ const installedSkills = useMemo(
+ () => ( skills ?? [] ).filter( ( skill ) => skill.installed ),
+ [ skills ]
+ );
+ const availableSkills = useMemo(
+ () => ( skills ?? [] ).filter( ( skill ) => ! skill.installed ),
+ [ skills ]
+ );
+ const isBusy = installSkill.isPending || installAllSkills.isPending || removeSkill.isPending;
+ const visibleError =
+ getErrorMessage( error ) ??
+ getErrorMessage( installSkill.error ) ??
+ getErrorMessage( installAllSkills.error ) ??
+ getErrorMessage( removeSkill.error );
+
+ const handleInstallAll = () => {
+ if ( availableSkills.length === 0 ) {
+ return;
+ }
+ installAllSkills.mutate( availableSkills.map( ( skill ) => skill.id ) );
+ };
+
+ return (
+
+
+
+
{ __( 'Skills' ) }
+
+ { __(
+ 'Skills are reusable instructions that teach agents how to complete specialized WordPress tasks. Enable the ones you want Studio to add to sites so agents have the right context before they start working.'
+ ) }{ ' ' }
+
+
+
+ { visibleError ? { visibleError }
: null }
+ { isLoading ? { __( 'Loading skills...' ) }
: null }
+ { ! isLoading && installedSkills.length === 0 && availableSkills.length === 0 ? (
+ { __( 'No skills are available.' ) }
+ ) : null }
+ { installedSkills.length > 0 ? (
+
+ { installedSkills.map( ( skill ) => (
+ removeSkill.mutate( skill.id ) }
+ />
+ ) ) }
+
+ ) : null }
+ { availableSkills.length > 0 ? (
+
+ { __( 'Install all' ) }
+
+ }
+ >
+ { availableSkills.map( ( skill ) => (
+ installSkill.mutate( skill.id ) }
+ />
+ ) ) }
+
+ ) : null }
+
+
+ );
+}
+
+function McpCopyButton( { text }: { text: string } ) {
+ const connector = useConnector();
+ const [ copied, setCopied ] = useState( false );
+
+ useEffect( () => {
+ if ( ! copied ) {
+ return;
+ }
+ const timeoutId = window.setTimeout( () => setCopied( false ), 2000 );
+ return () => window.clearTimeout( timeoutId );
+ }, [ copied ] );
+
+ const copyLabel = __( 'Copy MCP configuration' );
+ const copiedLabel = __( 'Copied' );
+ const tooltipLabel = copied ? copiedLabel : copyLabel;
+
+ const handleCopy = useCallback( () => {
+ void connector
+ .copyText( text )
+ .then( () => setCopied( true ) )
+ .catch( ( error ) => {
+ console.error( 'Failed to copy MCP configuration:', error );
+ } );
+ }, [ connector, text ] );
+
+ return (
+
+
+
+
+
+ }
+ />
+ }>
+ { tooltipLabel }
+
+
+
+ { copied ? copiedLabel : '' }
+
+
+ );
+}
+
+function McpSettingsPanel() {
+ const configJson = getMcpServerConfigJson();
+
+ return (
+
+
+
+
{ __( 'MCP' ) }
+
+ { __(
+ 'MCP lets other AI tools talk to Studio. Use it when you want an assistant outside Studio to create, configure, or inspect your local WordPress sites through the same site controls.'
+ ) }{ ' ' }
+
+
+
+
+
);
}
@@ -125,85 +990,86 @@ export function SettingsView( {
activeTab: TabId;
onTabChange: ( tab: TabId ) => void;
} ) {
+ const connector = useConnector();
const { data: saved, isLoading } = useUserPreferences();
const { data: installedApps } = useInstalledApps();
- const savePreferences = useSaveUserPreferences();
+ const { data: appGlobals, isLoading: isLoadingAppGlobals } = useAppGlobals();
+ const { mutate: savePreferences, isPending: isSavingPreferences } = useSaveUserPreferences();
+ const savedColorSchemeRef = useRef< ColorScheme | null >( null );
+ const previewedColorSchemeRef = useRef( false );
- const [ data, setData ] = useState< FormData | null >( null );
+ const [ data, setData ] = useState< PreferencesFormData | null >( null );
+ const [ submitError, setSubmitError ] = useState< string | null >( null );
useEffect( () => {
if ( saved ) {
- setData( toFormData( saved ) );
+ setData( toPreferencesFormData( saved ) );
+ savedColorSchemeRef.current = saved.colorScheme;
}
}, [ saved ] );
- const preferencesFields = useMemo< Field< FormData >[] >(
- () => [
- {
- id: 'editor',
- type: 'text',
- label: __( 'Preferred editor' ),
- elements: editorElements( installedApps ),
- },
- {
- id: 'terminal',
- type: 'text',
- label: __( 'Preferred terminal' ),
- elements: terminalElements( installedApps ),
- },
- {
- id: 'colorScheme',
- type: 'text',
- label: __( 'Appearance' ),
- elements: COLOR_SCHEME_ELEMENTS,
- },
- {
- id: 'locale',
- type: 'text',
- label: __( 'Language' ),
- elements: LOCALE_ELEMENTS,
- },
- ],
- [ installedApps ]
- );
+ const handleChange = useCallback( ( update: Partial< PreferencesFormData > ) => {
+ setSubmitError( null );
+ setData( ( prev ) => ( prev ? { ...prev, ...update } : prev ) );
+ }, [] );
- const preferencesForm = useMemo< Form >(
- () => ( {
- layout: { type: 'regular', labelPosition: 'top' },
- fields: [
- {
- id: 'apps',
- layout: { type: 'row' },
- children: [ 'editor', 'terminal' ],
- },
- 'colorScheme',
- 'locale',
- ],
- } ),
- []
+ const handleColorSchemeChange = useCallback(
+ ( colorScheme: ColorScheme ) => {
+ if ( ! isColorScheme( colorScheme ) ) {
+ return;
+ }
+ previewedColorSchemeRef.current = true;
+ void connector.previewColorScheme( colorScheme );
+ handleChange( { colorScheme } );
+ },
+ [ connector, handleChange ]
);
- const handleChange = useCallback( ( update: Record< string, unknown > ) => {
- setData( ( prev ) => ( prev ? { ...prev, ...( update as Partial< FormData > ) } : prev ) );
- }, [] );
-
- if ( isLoading || ! data || ! saved ) {
- return { __( 'Loading…' ) }
;
- }
+ useEffect( () => {
+ return () => {
+ if ( previewedColorSchemeRef.current && savedColorSchemeRef.current ) {
+ void connector.previewColorScheme( savedColorSchemeRef.current );
+ }
+ };
+ }, [ connector ] );
- const patch = diffFromSaved( data, saved );
+ const patch = useMemo(
+ () => ( data && saved ? diffPreferencesFromSaved( data, saved ) : {} ),
+ [ data, saved ]
+ );
const isDirty = Object.keys( patch ).length > 0;
- const canSubmit = isDirty && ! savePreferences.isPending;
+ const canSubmit = isDirty;
+ const showNativePreferences = appGlobals ? appGlobals.platform !== 'browser' : false;
+ const showStudioCliToggle = showNativePreferences && appGlobals?.isWindowsStore === false;
- const handleSubmit = ( event: FormEvent ) => {
- event.preventDefault();
- if ( ! canSubmit ) return;
+ useBlocker( {
+ disabled: ! isDirty,
+ enableBeforeUnload: isDirty,
+ shouldBlockFn: ( { current, next } ) => {
+ if ( current.pathname === next.pathname ) {
+ return false;
+ }
+
+ return ! window.confirm(
+ __( 'You have unsaved settings. If you leave this screen, your changes will be lost.' )
+ );
+ },
+ } );
+
+ const submitPreferences = useCallback( () => {
+ if ( ! canSubmit || isSavingPreferences ) return;
+ setSubmitError( null );
// Translations are loaded once at bootstrap; the rest of the app imports
// `__` from `@wordpress/i18n` directly and doesn't subscribe to locale
// changes. Reload the window so every string re-renders in the new
// language after a successful save.
const localeChanged = 'locale' in patch;
- savePreferences.mutate( patch, {
+ savePreferences( patch, {
onSuccess: async () => {
+ setSubmitError( null );
+ if ( 'colorScheme' in patch && patch.colorScheme ) {
+ previewedColorSchemeRef.current = false;
+ savedColorSchemeRef.current = patch.colorScheme;
+ }
if ( localeChanged ) {
// The persister is throttled (~1s), so a fresh `setQueryData`
// might not hit localStorage before we navigate. Drop the
@@ -213,12 +1079,49 @@ export function SettingsView( {
window.location.reload();
}
},
+ onError: ( error ) => {
+ setSubmitError( getErrorMessage( error ) ?? __( 'Unable to save settings.' ) );
+ },
} );
+ }, [ canSubmit, isSavingPreferences, patch, savePreferences ] );
+
+ useEffect( () => {
+ const handleKeyDown = ( event: globalThis.KeyboardEvent ) => {
+ const isSaveShortcut =
+ event.key.toLowerCase() === 's' &&
+ ( event.metaKey || event.ctrlKey ) &&
+ ! event.altKey &&
+ ! event.shiftKey;
+ if ( ! isSaveShortcut ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ submitPreferences();
+ };
+
+ window.addEventListener( 'keydown', handleKeyDown );
+ return () => window.removeEventListener( 'keydown', handleKeyDown );
+ }, [ submitPreferences ] );
+
+ if ( isLoading || isLoadingAppGlobals || ! data || ! saved || ! appGlobals ) {
+ return { __( 'Loading...' ) }
;
+ }
+
+ const handleSelectDefaultDirectory = async () => {
+ const directory = await connector.selectDefaultSiteDirectory( data.defaultSiteDirectory );
+ if ( directory ) {
+ handleChange( { defaultSiteDirectory: directory } );
+ }
+ };
+
+ const handleSubmit = ( event: FormEvent ) => {
+ event.preventDefault();
+ submitPreferences();
};
return (
-
{
@@ -227,41 +1130,39 @@ export function SettingsView( {
}
} }
>
-
-
{ __( 'Settings' ) }
-
-
-
-
- { __( 'Preferences' ) }
-
-
-
+
diff --git a/apps/ui/src/components/settings-view/preferences.test.ts b/apps/ui/src/components/settings-view/preferences.test.ts
new file mode 100644
index 0000000000..4cab811e7f
--- /dev/null
+++ b/apps/ui/src/components/settings-view/preferences.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import { UNSET, diffPreferencesFromSaved, toPreferencesFormData } from './preferences';
+import type { UserPreferences } from '@/data/core';
+
+const SAVED_PREFERENCES: UserPreferences = {
+ editor: 'zed',
+ terminal: 'terminal',
+ colorScheme: 'system',
+ locale: 'en',
+ defaultSiteDirectory: '/Users/example/Studio',
+ studioCliInstalled: false,
+};
+
+describe( 'settings preference helpers', () => {
+ it( 'resolves form defaults from saved preferences in one place', () => {
+ expect(
+ toPreferencesFormData( {
+ ...SAVED_PREFERENCES,
+ editor: null,
+ terminal: null,
+ locale: 'missing-locale',
+ } )
+ ).toEqual( {
+ editor: UNSET,
+ terminal: UNSET,
+ colorScheme: 'system',
+ locale: 'en',
+ defaultSiteDirectory: '/Users/example/Studio',
+ studioCliInstalled: false,
+ } );
+ } );
+
+ it( 'returns an empty save diff when form values match saved defaults', () => {
+ expect(
+ diffPreferencesFromSaved( toPreferencesFormData( SAVED_PREFERENCES ), SAVED_PREFERENCES )
+ ).toEqual( {} );
+ } );
+
+ it( 'diffs default site directory and Studio CLI state with other preference fields', () => {
+ expect(
+ diffPreferencesFromSaved(
+ {
+ editor: UNSET,
+ terminal: 'iterm',
+ colorScheme: 'dark',
+ locale: 'es',
+ defaultSiteDirectory: '/Users/example/Sites',
+ studioCliInstalled: true,
+ },
+ SAVED_PREFERENCES
+ )
+ ).toEqual( {
+ editor: null,
+ terminal: 'iterm',
+ colorScheme: 'dark',
+ locale: 'es',
+ defaultSiteDirectory: '/Users/example/Sites',
+ studioCliInstalled: true,
+ } );
+ } );
+} );
diff --git a/apps/ui/src/components/settings-view/preferences.ts b/apps/ui/src/components/settings-view/preferences.ts
new file mode 100644
index 0000000000..3a5c86007a
--- /dev/null
+++ b/apps/ui/src/components/settings-view/preferences.ts
@@ -0,0 +1,60 @@
+import { isSupportedLocale } from '@studio/common/lib/locale';
+import type {
+ ColorScheme,
+ SupportedEditor,
+ SupportedLocale,
+ SupportedTerminal,
+ UserPreferences,
+ WritableUserPreferences,
+} from '@/data/core';
+
+export const UNSET = '' as const;
+
+export interface PreferencesFormData {
+ editor: SupportedEditor | typeof UNSET;
+ terminal: SupportedTerminal | typeof UNSET;
+ colorScheme: ColorScheme;
+ locale: SupportedLocale;
+ defaultSiteDirectory: string;
+ studioCliInstalled: boolean;
+}
+
+// The saved locale can be any string the main process resolved (including ones
+// outside our catalog). Clamp to a SupportedLocale so form controls always have
+// a valid option selected.
+export function resolveFormLocale( locale: string | undefined ): SupportedLocale {
+ return isSupportedLocale( locale ) ? locale : 'en';
+}
+
+export function toPreferencesFormData( prefs: UserPreferences ): PreferencesFormData {
+ return {
+ editor: prefs.editor ?? UNSET,
+ terminal: prefs.terminal ?? UNSET,
+ colorScheme: prefs.colorScheme,
+ locale: resolveFormLocale( prefs.locale ),
+ defaultSiteDirectory: prefs.defaultSiteDirectory,
+ studioCliInstalled: prefs.studioCliInstalled,
+ };
+}
+
+export function diffPreferencesFromSaved(
+ next: PreferencesFormData,
+ saved: UserPreferences
+): Partial< WritableUserPreferences > {
+ const patch: Partial< WritableUserPreferences > = {};
+ const nextEditor: SupportedEditor | null = next.editor === UNSET ? null : next.editor;
+ const nextTerminal: SupportedTerminal | null = next.terminal === UNSET ? null : next.terminal;
+
+ if ( nextEditor !== saved.editor ) patch.editor = nextEditor;
+ if ( nextTerminal !== saved.terminal ) patch.terminal = nextTerminal;
+ if ( next.colorScheme !== saved.colorScheme ) patch.colorScheme = next.colorScheme;
+ if ( next.locale !== resolveFormLocale( saved.locale ) ) patch.locale = next.locale;
+ if ( next.defaultSiteDirectory !== saved.defaultSiteDirectory ) {
+ patch.defaultSiteDirectory = next.defaultSiteDirectory;
+ }
+ if ( next.studioCliInstalled !== saved.studioCliInstalled ) {
+ patch.studioCliInstalled = next.studioCliInstalled;
+ }
+
+ return patch;
+}
diff --git a/apps/ui/src/components/settings-view/style.module.css b/apps/ui/src/components/settings-view/style.module.css
index 125d0b4e30..bb6ea5b4d5 100644
--- a/apps/ui/src/components/settings-view/style.module.css
+++ b/apps/ui/src/components/settings-view/style.module.css
@@ -1,4 +1,11 @@
.root {
+ --settings-content-width: 760px;
+ --settings-form-width: 560px;
+ --settings-control-width: 300px;
+ --settings-divider-border: 1px dotted
+ color-mix(in srgb, var(--wpds-color-stroke-surface-neutral) 72%, transparent);
+ --settings-row-padding-block: calc(var(--wpds-dimension-padding-md) + 2px);
+
display: flex;
flex-direction: column;
height: 100%;
@@ -8,52 +15,132 @@
.state {
padding: var(--wpds-dimension-padding-xl);
color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-md);
}
.header {
- display: flex;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: var(--wpds-dimension-padding-sm);
- padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-2xl);
- min-height: 46px;
- font-size: var(--wpds-typography-font-size-sm);
+ min-height: 54px;
+ padding: 0;
+ border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral);
color: var(--wpds-color-fg-content-neutral);
-webkit-app-region: drag;
}
-.toggleSpacer {
- display: block;
- flex-shrink: 0;
- width: 118px;
+.headerStart,
+.headerTabs,
+.headerActions {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+}
+
+.headerStart {
+ grid-column: 1;
+ justify-self: start;
+ pointer-events: none;
+ -webkit-app-region: drag;
+}
+
+.headerTabs {
+ grid-column: 2;
align-self: stretch;
+ align-items: stretch;
+ justify-self: center;
+ -webkit-app-region: drag;
+}
+
+.headerTabList {
+ height: 100%;
+ align-items: center !important;
+ gap: 0 !important;
+ margin-bottom: -1px;
+ overflow: visible !important;
-webkit-app-region: no-drag;
}
-.toggleSpacerFullscreen {
- display: block;
- flex-shrink: 0;
- width: calc(var(--wpds-dimension-padding-xl) + 24px);
- align-self: stretch;
+.headerTabList :global([role='tab']) {
+ box-sizing: border-box;
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+ height: 40px !important;
+ padding-block: 0 !important;
+ padding-inline: var(--wpds-dimension-padding-lg) !important;
+ border-radius: var(--wpds-border-radius-sm);
+ font-weight: 500 !important;
+ outline: 0 solid transparent;
+ outline-offset: 2px;
-webkit-app-region: no-drag;
}
-.titleBlock {
- max-width: 760px;
- margin: 0 auto;
- padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) 0;
- width: 100%;
+.headerTabList :global([role='tab'][aria-selected='true']) {
+ font-weight: 500 !important;
}
-.tabsBar {
- width: 100%;
- border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral);
- margin-top: var(--wpds-dimension-padding-xl);
+.headerTabList :global([role='tab'])::after {
+ content: none !important;
}
-.tabsBarInner {
- max-width: 760px;
- margin: 0 auto;
- padding: 0 var(--wpds-dimension-padding-xl);
+.headerTabList
+ :global([role='tab']:not([aria-disabled='true']):is(:focus-visible, [data-focus-visible])) {
+ box-shadow: none !important;
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand) !important;
+ outline-offset: 2px !important;
+}
+
+@media not (prefers-reduced-motion) {
+ .headerTabList::before {
+ transition-property: transform, border-radius, border-block, border-bottom-width;
+ transition-duration: 180ms;
+ transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
+ }
+}
+
+.headerActions {
+ grid-column: 3;
+ justify-self: end;
+ padding-inline-end: var(--wpds-dimension-padding-md);
+ -webkit-app-region: no-drag;
+}
+
+.saveTooltipTrigger {
+ display: inline-flex;
+}
+
+.saveButton {
+ min-inline-size: 88px;
+}
+
+.saveButton:disabled,
+.saveButton[aria-disabled='true'] {
+ pointer-events: none;
+}
+
+.saveButton[aria-disabled='true'],
+.saveButton[aria-disabled='true']:is(:hover, :focus-visible, [data-focus-visible]) {
+ background-color: var(--wpds-color-bg-interactive-neutral-strong-disabled) !important;
+ color: var(--wpds-color-fg-interactive-neutral-strong-disabled) !important;
+}
+
+.toggleSpacer {
+ display: block;
+ flex-shrink: 0;
+ width: calc(118px - var(--wpds-dimension-padding-xs));
+ height: 100%;
+ -webkit-app-region: drag;
+}
+
+.toggleSpacerFullscreen {
+ display: block;
+ flex-shrink: 0;
+ width: calc(var(--wpds-dimension-padding-md) + 24px - var(--wpds-dimension-padding-xs));
+ height: 100%;
+ -webkit-app-region: drag;
}
.scroll {
@@ -63,9 +150,11 @@
}
.contentBlock {
- max-width: 760px;
+ box-sizing: border-box;
+ width: 100%;
+ max-width: var(--settings-content-width);
margin: 0 auto;
- padding: calc(var(--wpds-dimension-padding-2xl) * 1.5) var(--wpds-dimension-padding-xl)
+ padding: var(--wpds-dimension-padding-2xl) var(--wpds-dimension-padding-xl)
var(--wpds-dimension-padding-2xl);
}
@@ -75,8 +164,737 @@
gap: var(--wpds-dimension-padding-xl);
}
-.actions {
+.preferencesPanel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-xl);
+ width: 100%;
+ max-width: var(--settings-form-width);
+ margin: 0 auto;
+}
+
+.preferenceSectionGroup {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ padding: var(--wpds-dimension-padding-lg) var(--wpds-dimension-padding-xl)
+ var(--wpds-dimension-padding-xl);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 8px;
+ background: color-mix(
+ in srgb,
+ var(--wpds-color-bg-surface-neutral-weak) 56%,
+ transparent
+ );
+}
+
+.preferenceSectionHeading {
+ margin: 0 0 var(--wpds-dimension-padding-md);
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-md);
+}
+
+.preferenceRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) max-content;
+ align-items: center;
+ column-gap: var(--wpds-dimension-padding-xl);
+ padding-block: var(--settings-row-padding-block);
+ border-bottom: var(--settings-divider-border);
+}
+
+.preferenceRow:last-child {
+ padding-block-end: 0;
+ border-bottom: 0;
+}
+
+.preferenceText {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-xs);
+ min-width: 0;
+}
+
+.preferenceText h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 400;
+ line-height: var(--wpds-typography-line-height-sm);
+ white-space: nowrap;
+}
+
+.preferenceText p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.preferenceControl {
display: flex;
justify-content: flex-end;
- gap: var(--wpds-dimension-padding-sm);
+ min-width: 0;
+ inline-size: 100%;
+}
+
+.appearancePicker {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(72px, 1fr));
+ align-items: center;
+ gap: 0;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-interactive-neutral-weak);
+ color: var(--wpds-color-fg-interactive-neutral);
+ font-family: var(--wpds-typography-font-family-body);
+ font-size: var(--wpds-typography-font-size-md);
+ line-height: 1;
+}
+
+.appearancePicker:hover {
+ border-color: var(--wpds-color-stroke-surface-neutral);
+}
+
+.appearancePicker::before {
+ content: '';
+ position: absolute;
+ inset-block: calc(var(--wpds-border-width-xs) * -1);
+ inset-inline-start: 0;
+ box-sizing: border-box;
+ inline-size: calc(100% / 3);
+ border: 1px solid var(--wpds-color-stroke-interactive-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-interactive-neutral-weak);
+ pointer-events: none;
+ transform: translateX(calc(var(--appearance-active-index, 0) * 100%));
+}
+
+.appearancePicker[data-active-index='0'] {
+ --appearance-active-index: 0;
+}
+
+.appearancePicker[data-active-index='1'] {
+ --appearance-active-index: 1;
+}
+
+.appearancePicker[data-active-index='2'] {
+ --appearance-active-index: 2;
+}
+
+@media not (prefers-reduced-motion) {
+ .appearancePicker::before {
+ transition: transform 180ms cubic-bezier(0.2, 0, 0, 1);
+ }
+}
+
+.appearanceButton {
+ position: relative;
+ z-index: 1;
+ min-height: 32px;
+ padding: 0 var(--wpds-dimension-padding-lg);
+ border: 0;
+ border-radius: var(--wpds-border-radius-sm);
+ background: transparent;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font: inherit;
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ cursor: pointer;
+}
+
+.appearanceButton:hover,
+.appearanceButtonActive {
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.appearanceButtonActive {
+ background: transparent;
+}
+
+.appearanceButton:focus-visible,
+.selectControl:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.selectControl {
+ inline-size: 156px;
+ max-inline-size: 100%;
+}
+
+.selectControl > * {
+ inline-size: 100%;
+}
+
+.pathPickerButton {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-lg);
+ inline-size: var(--settings-control-width);
+ max-inline-size: 100%;
+ min-block-size: 40px;
+ padding: 0 var(--wpds-dimension-padding-xl);
+ border: 1px solid var(--wpds-color-stroke-interactive-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-surface-neutral);
+ color: var(--wpds-color-fg-content-neutral);
+ font-family: var(--wpds-typography-font-family-body);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ text-align: start;
+ cursor: pointer;
+}
+
+.pathPickerButton:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral-active);
+}
+
+.pathPickerButton:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.pathPickerValue,
+.pathPickerPlaceholder {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pathPickerPlaceholder {
+ color: var(--wpds-color-fg-content-neutral-weak);
+}
+
+.pathPickerIcon {
+ flex-shrink: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ fill: currentColor;
+}
+
+.pathPickerIcon path {
+ fill: currentColor;
+}
+
+.cliHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-xl);
+}
+
+.cliHeading {
+ margin-block-end: 0;
+}
+
+.cliHeader :global(.components-form-toggle .components-form-toggle__track) {
+ background-color: var(--wpds-color-bg-track-neutral-weak);
+ border-color: var(--wpds-color-stroke-interactive-neutral);
+}
+
+.cliHeader :global(.components-form-toggle .components-form-toggle__thumb) {
+ background-color: var(--wpds-color-bg-thumb-neutral-weak);
+}
+
+.cliHeader :global(.components-form-toggle.is-checked .components-form-toggle__track) {
+ background-color: var(--wpds-color-bg-interactive-brand-strong);
+ border-color: var(--wpds-color-stroke-interactive-brand);
+}
+
+.cliHeader :global(.components-form-toggle.is-checked .components-form-toggle__thumb) {
+ background-color: var(--wpds-color-fg-interactive-brand-strong);
+}
+
+.cliHeader :global(.components-form-toggle .components-form-toggle__input:focus + .components-form-toggle__track) {
+ box-shadow:
+ 0 0 0 var(--wpds-border-width-focus) var(--wpds-color-bg-surface-neutral),
+ 0 0 0 calc(2 * var(--wpds-border-width-focus)) var(--wpds-color-stroke-focus-brand);
+}
+
+.cliDescription {
+ max-inline-size: 460px;
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.accountSummaryHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-xl);
+ min-width: 0;
+}
+
+.accountSectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-xl);
+ margin-block-end: var(--wpds-dimension-padding-xl);
+}
+
+.accountHeading {
+ margin-block-end: 0;
+}
+
+.accountSummaryIdentity {
+ display: flex;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-md);
+ min-width: 0;
+}
+
+.accountSummaryAvatar {
+ flex: 0 0 44px;
+ inline-size: 44px;
+ block-size: 44px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.accountSummaryDetails {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.accountSummaryDetails h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.accountSummaryDetails p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+ overflow-wrap: anywhere;
+}
+
+.usagePanel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-xl);
+ width: 100%;
+ max-width: var(--settings-form-width);
+ margin: 0 auto;
+}
+
+.usageSection {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--wpds-dimension-padding-md);
+ padding-block: var(--wpds-dimension-padding-lg);
+ border-top: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.usageSection:last-child {
+ padding-block-end: 0;
+}
+
+.usageSectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-md);
+ min-width: 0;
+}
+
+.usageSectionHeader h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.usageSection p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.accountActions {
+ display: flex;
+ justify-content: flex-start;
+ gap: var(--wpds-dimension-padding-md);
+ flex-wrap: wrap;
+}
+
+.accountActionButton {
+ display: inline-flex !important;
+ align-items: center;
+ gap: 4px;
+}
+
+.previewUsageText {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.progressTrack {
+ overflow: hidden;
+ inline-size: 100%;
+ block-size: 6px;
+ border-radius: 999px;
+ background: var(--wpds-color-stroke-surface-neutral);
+}
+
+.progressValue {
+ block-size: 100%;
+ border-radius: inherit;
+ background: var(--wpds-color-fg-interactive-brand);
+}
+
+.previewActionsButton {
+ flex-shrink: 0;
+}
+
+.usageSectionAction {
+ align-self: flex-start;
+}
+
+.aiCreditsTrack {
+ position: relative;
+ background: color-mix(
+ in srgb,
+ var(--wpds-color-fg-interactive-brand) 14%,
+ var(--wpds-color-stroke-surface-neutral)
+ );
+}
+
+.aiCreditsMeterValue {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 32%, transparent),
+ color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 14%, transparent)
+ ),
+ repeating-linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--wpds-color-fg-interactive-brand) 18%, transparent) 0 8px,
+ transparent 8px 16px
+ );
+}
+
+.keyboardPanel,
+.skillsPanel,
+.mcpPanel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-xl);
+ width: 100%;
+ max-width: var(--settings-form-width);
+ margin: 0 auto;
+}
+
+.settingsPanelSection {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--wpds-dimension-padding-lg);
+ padding: var(--wpds-dimension-padding-lg) var(--wpds-dimension-padding-xl)
+ var(--wpds-dimension-padding-xl);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 8px;
+ background: color-mix(
+ in srgb,
+ var(--wpds-color-bg-surface-neutral-weak) 56%,
+ transparent
+ );
+}
+
+.settingsPanelHeader {
+ display: flex;
+ flex-direction: column;
+ gap: var(--wpds-dimension-padding-xs);
+}
+
+.settingsPanelHeader h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-lg);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-md);
+}
+
+.settingsPanelHeader p {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-md);
+}
+
+.shortcutSection {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.shortcutSectionHeader {
+ padding-block-end: var(--wpds-dimension-padding-md);
+}
+
+.shortcutSectionHeader h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.shortcutList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.shortcutRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-xl);
+ padding-block: var(--wpds-dimension-padding-lg);
+ border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.shortcutRow:last-child {
+ border-bottom: 0;
+}
+
+.shortcutName {
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-sm);
+ font-weight: 500;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.shortcutKeys {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--wpds-dimension-padding-xs);
+ flex-wrap: wrap;
+}
+
+.shortcutKey {
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-inline-size: 26px;
+ min-block-size: 26px;
+ padding: 0 var(--wpds-dimension-padding-xs);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ background: var(--wpds-color-bg-surface-neutral);
+ color: var(--wpds-color-fg-content-neutral);
+ font-family: var(--wpds-typography-font-family-body);
+ font-size: var(--wpds-typography-font-size-xs);
+ font-weight: 500;
+ line-height: 1;
+}
+
+.errorMessage {
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ border: 1px solid color-mix(in srgb, var(--wpds-color-fg-content-error) 35%, transparent);
+ border-radius: 6px;
+ background: color-mix(
+ in srgb,
+ var(--wpds-color-fg-content-error) 8%,
+ var(--wpds-color-bg-surface-neutral-strong)
+ );
+ color: var(--wpds-color-fg-content-error);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.skillSection {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.skillSectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--wpds-dimension-padding-md);
+ padding-block-end: var(--wpds-dimension-padding-md);
+}
+
+.skillSectionHeader h2 {
+ margin: 0;
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.skillSectionAction {
+ flex-shrink: 0;
+}
+
+.skillList {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.skillRow {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--wpds-dimension-padding-xl);
+ padding-block: var(--wpds-dimension-padding-lg);
+ border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral);
+}
+
+.skillRow:last-child {
+ border-bottom: 0;
+}
+
+.skillDetails {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.skillName {
+ color: var(--wpds-color-fg-content-neutral);
+ font-size: var(--wpds-typography-font-size-sm);
+ font-weight: 600;
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.skillDescription {
+ color: var(--wpds-color-fg-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-sm);
+}
+
+.codeBlockWrap {
+ position: relative;
+}
+
+.codeBlock {
+ margin: 0;
+ padding: var(--wpds-dimension-padding-md);
+ padding-inline-end: calc(var(--wpds-dimension-padding-xl) + 32px);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral);
+ border-radius: 6px;
+ background: var(--wpds-color-bg-surface-neutral);
+ color: var(--wpds-color-fg-content-neutral);
+ font-family: var(--wpds-typography-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-md);
+ overflow-x: auto;
+ white-space: pre;
+}
+
+.mcpCopyButtonContainer {
+ position: absolute;
+ inset-block-start: var(--wpds-dimension-padding-sm);
+ inset-inline-end: var(--wpds-dimension-padding-sm);
+ z-index: 1;
+}
+
+.mcpCopyButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: var(--wpds-border-radius-sm);
+ background-color: color-mix(
+ in srgb,
+ var(--wpds-color-bg-surface-neutral) 82%,
+ transparent
+ );
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+ color: var(--wpds-color-fg-content-neutral-weak);
+ line-height: 1;
+ cursor: pointer;
+ transition: color 0.12s ease, background-color 0.12s ease;
+}
+
+.mcpCopyButton:hover,
+.mcpCopyButton:focus-visible {
+ background-color: var(--wpds-color-bg-interactive-neutral-weak);
+ color: var(--wpds-color-fg-content-neutral);
+}
+
+.mcpCopyButton:focus-visible {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand);
+ outline-offset: 2px;
+}
+
+.visuallyHidden {
+ position: absolute;
+ overflow: hidden;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ border: 0;
+ margin: -1px;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+}
+
+@media (max-width: 640px) {
+ .preferenceRow {
+ align-items: stretch;
+ grid-template-columns: 1fr;
+ row-gap: var(--wpds-dimension-padding-sm);
+ }
+
+ .accountSummaryHeader {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .accountSectionHeader {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .preferenceText h2 {
+ white-space: normal;
+ }
+
+ .shortcutRow,
+ .skillRow {
+ align-items: stretch;
+ grid-template-columns: 1fr;
+ }
+
+ .shortcutKeys {
+ justify-content: flex-start;
+ }
}
diff --git a/apps/ui/src/components/sidebar-layout/index.tsx b/apps/ui/src/components/sidebar-layout/index.tsx
index b3ce65931a..6dff2d2968 100644
--- a/apps/ui/src/components/sidebar-layout/index.tsx
+++ b/apps/ui/src/components/sidebar-layout/index.tsx
@@ -63,6 +63,7 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
/>
) : null }
+ { children }
{ collapsed ? (
) : null }
- { children }
{ sidebarResize.isResizing ? : null }
diff --git a/apps/ui/src/components/sidebar-layout/style.module.css b/apps/ui/src/components/sidebar-layout/style.module.css
index a89f1f9ed8..995d1653f0 100644
--- a/apps/ui/src/components/sidebar-layout/style.module.css
+++ b/apps/ui/src/components/sidebar-layout/style.module.css
@@ -5,6 +5,9 @@
}
.sidebar {
+ --sidebar-row-background-hover: var(--wpds-color-bg-interactive-neutral-weak-active);
+ --sidebar-row-background-active: var(--wpds-color-bg-interactive-neutral-weak-active);
+
width: var(--sidebar-width, 320px);
/* The 240px floor is owned by useResizablePanel, which clamps
--sidebar-width on every path. A CSS min-width here would be redundant
@@ -79,6 +82,7 @@
padding: var(--wpds-dimension-padding-sm) 0;
min-height: 46px;
z-index: 30;
+ pointer-events: auto;
-webkit-app-region: no-drag;
}
diff --git a/apps/ui/src/components/site-list/index.test.tsx b/apps/ui/src/components/site-list/index.test.tsx
index 02c1267f7c..58840513d8 100644
--- a/apps/ui/src/components/site-list/index.test.tsx
+++ b/apps/ui/src/components/site-list/index.test.tsx
@@ -19,10 +19,19 @@ import { SiteList } from './index';
import type { SiteDetails } from '@/data/core';
import type { ReactNode } from 'react';
+const routerState = vi.hoisted( () => ( {
+ pathname: '/',
+} ) );
+
vi.mock( '@tanstack/react-router', () => ( {
Link: ( props: { children?: ReactNode } ) => { props.children },
useNavigate: () => vi.fn(),
useParams: () => ( {} ),
+ useRouterState: ( {
+ select,
+ }: {
+ select: ( state: { location: { pathname: string } } ) => unknown;
+ } ) => select( { location: { pathname: routerState.pathname } } ),
} ) );
vi.mock( '@/data/core', () => ( {
@@ -77,6 +86,7 @@ describe( 'SiteList', () => {
beforeEach( () => {
vi.clearAllMocks();
+ routerState.pathname = '/';
useConnectorMock.mockReturnValue( {
openExternalUrl: vi.fn(),
@@ -105,6 +115,8 @@ describe( 'SiteList', () => {
terminal: 'terminal',
colorScheme: 'system',
locale: undefined,
+ defaultSiteDirectory: '/Users/example/Studio',
+ studioCliInstalled: false,
},
} );
useSitesMock.mockReturnValue( {
@@ -145,6 +157,34 @@ describe( 'SiteList', () => {
expect( actionGlyph?.querySelector( 'rect' ) ).toHaveAttribute( 'width', '8' );
expect( actionGlyph?.querySelector( 'path' ) ).not.toBeInTheDocument();
} );
+
+ it( 'opens the most recent site by default on the dashboard root', () => {
+ render( );
+
+ expect( screen.getByRole( 'button', { name: 'Stopped Site' } ) ).toHaveAttribute(
+ 'aria-expanded',
+ 'true'
+ );
+ expect( screen.getByRole( 'button', { name: 'Running Site' } ) ).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ } );
+
+ it( 'does not open the most recent site by default on settings routes', () => {
+ routerState.pathname = '/settings';
+
+ render( );
+
+ expect( screen.getByRole( 'button', { name: 'Stopped Site' } ) ).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ expect( screen.getByRole( 'button', { name: 'Running Site' } ) ).toHaveAttribute(
+ 'aria-expanded',
+ 'false'
+ );
+ } );
} );
function createSite( overrides: Partial< SiteDetails > = {} ): SiteDetails {
diff --git a/apps/ui/src/components/site-list/index.tsx b/apps/ui/src/components/site-list/index.tsx
index 0486ae23bd..88c2751232 100644
--- a/apps/ui/src/components/site-list/index.tsx
+++ b/apps/ui/src/components/site-list/index.tsx
@@ -1,4 +1,4 @@
-import { Link, useNavigate, useParams } from '@tanstack/react-router';
+import { Link, useNavigate, useParams, useRouterState } from '@tanstack/react-router';
import { __, sprintf } from '@wordpress/i18n';
import {
box,
@@ -669,6 +669,7 @@ function findActiveSiteKey(
export function SiteList() {
const { data: sites, isLoading: sitesLoading } = useSites();
const { data: sessions, isLoading: sessionsLoading } = useSessions();
+ const pathname = useRouterState( { select: ( state ) => state.location.pathname } );
const params = useParams( { strict: false } ) as { sessionId?: string; siteId?: string };
const activeSessionId = params.sessionId;
const activeSiteId = params.siteId;
@@ -679,17 +680,18 @@ export function SiteList() {
[ groups, activeSessionId, activeSiteId ]
);
- // Expansion is derived: by default the active site (or, if none, the
- // MRU site — first in the list) is open. Manual toggles are stored as
- // overrides so the user's explicit choice wins until they toggle again.
+ // Expansion is derived: by default the active site is open. On the root
+ // dashboard, open the MRU site when there is no route-selected site yet.
+ // Manual toggles are stored as overrides so the user's explicit choice wins.
const mruKey = groups[ 0 ]?.key;
+ const shouldOpenMruSite = pathname === '/' && ! activeSessionId && ! activeSiteId;
const [ overrides, setOverrides ] = useState< Record< string, boolean > >( {} );
const isOpen = ( key: string ): boolean => {
if ( key in overrides ) {
return overrides[ key ];
}
- return key === activeSiteKey || ( ! activeSiteKey && key === mruKey );
+ return key === activeSiteKey || ( shouldOpenMruSite && ! activeSiteKey && key === mruKey );
};
const toggleSite = ( key: string ) => {
diff --git a/apps/ui/src/components/site-list/style.module.css b/apps/ui/src/components/site-list/style.module.css
index 57a47909d0..e204d9267a 100644
--- a/apps/ui/src/components/site-list/style.module.css
+++ b/apps/ui/src/components/site-list/style.module.css
@@ -1,7 +1,7 @@
.root {
--site-list-row-height: 34px;
- --site-list-row-background-hover: rgb(0 0 0 / 4%);
- --site-list-row-background-active: rgb(0 0 0 / 7%);
+ --site-list-row-background-hover: var(--sidebar-row-background-hover);
+ --site-list-row-background-active: var(--sidebar-row-background-active);
--site-list-row-leading-inset: var(--wpds-dimension-padding-xs);
--site-list-row-outer-inset: calc(
var(--wpds-dimension-padding-lg) - var(--site-list-row-leading-inset)
@@ -30,8 +30,6 @@
@media (prefers-color-scheme: dark) {
.root {
- --site-list-row-background-hover: rgb(255 255 255 / 8%);
- --site-list-row-background-active: rgb(255 255 255 / 12%);
--site-list-pending-indicator-bg: #facc15;
--site-list-pending-indicator-dot: #422006;
}
diff --git a/apps/ui/src/components/user-menu/index.test.tsx b/apps/ui/src/components/user-menu/index.test.tsx
new file mode 100644
index 0000000000..424b247fd9
--- /dev/null
+++ b/apps/ui/src/components/user-menu/index.test.tsx
@@ -0,0 +1,145 @@
+import '@testing-library/jest-dom/vitest';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useAuthUser, useLogin } from '@/data/queries/use-auth-user';
+import { useUserPreferences } from '@/data/queries/use-user-preferences';
+import { UserMenu } from './index';
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+
+const navigate = vi.fn();
+const loginMutate = vi.fn();
+let pathname = '/';
+
+vi.mock( '@tanstack/react-router', () => ( {
+ useNavigate: () => navigate,
+ useRouterState: ( {
+ select,
+ }: {
+ select: ( state: { location: { pathname: string } } ) => unknown;
+ } ) => select( { location: { pathname } } ),
+} ) );
+
+vi.mock( '@wordpress/ui', () => ( {
+ Button: ( {
+ children,
+ tone,
+ variant,
+ size,
+ nativeButton,
+ ...props
+ }: ButtonHTMLAttributes< HTMLButtonElement > & {
+ children?: ReactNode;
+ tone?: string;
+ variant?: string;
+ size?: string;
+ nativeButton?: boolean;
+ } ) => {
+ void tone;
+ void variant;
+ void size;
+ void nativeButton;
+ return ;
+ },
+ IconButton: ( {
+ label,
+ icon,
+ tone,
+ variant,
+ size,
+ ...props
+ }: ButtonHTMLAttributes< HTMLButtonElement > & {
+ label: string;
+ icon?: unknown;
+ tone?: string;
+ variant?: string;
+ size?: string;
+ } ) => {
+ void icon;
+ void tone;
+ void variant;
+ void size;
+ return (
+
+ );
+ },
+} ) );
+
+vi.mock( '@/components/gravatar', () => ( {
+ Gravatar: () => ,
+} ) );
+
+vi.mock( '@/data/queries/use-auth-user', () => ( {
+ useAuthUser: vi.fn(),
+ useLogin: vi.fn(),
+} ) );
+
+vi.mock( '@/data/queries/use-user-preferences', () => ( {
+ useUserPreferences: vi.fn(),
+} ) );
+
+vi.mock( '@/hooks/use-prefers-color-scheme', () => ( {
+ usePrefersColorScheme: () => 'dark',
+} ) );
+
+const useAuthUserMock = vi.mocked( useAuthUser );
+const useLoginMock = vi.mocked( useLogin );
+const useUserPreferencesMock = vi.mocked( useUserPreferences );
+
+describe( 'UserMenu', () => {
+ beforeEach( () => {
+ pathname = '/';
+ navigate.mockClear();
+ loginMutate.mockClear();
+ useLoginMock.mockReturnValue( {
+ mutate: loginMutate,
+ } as unknown as ReturnType< typeof useLogin > );
+ useUserPreferencesMock.mockReturnValue( {
+ data: { colorScheme: 'system' },
+ } as ReturnType< typeof useUserPreferences > );
+ } );
+
+ it( 'opens account settings directly for signed-in users', () => {
+ useAuthUserMock.mockReturnValue( {
+ data: { displayName: 'Ada Lovelace', email: 'ada@example.com' },
+ } as ReturnType< typeof useAuthUser > );
+
+ render( );
+
+ expect( screen.queryByRole( 'button', { name: 'Appearance' } ) ).not.toBeInTheDocument();
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Open account settings' } ) );
+
+ expect( navigate ).toHaveBeenCalledWith( {
+ to: '/settings',
+ } );
+ } );
+
+ it( 'highlights the signed-in account row while settings is active', () => {
+ pathname = '/settings';
+ useAuthUserMock.mockReturnValue( {
+ data: { displayName: 'Ada Lovelace', email: 'ada@example.com' },
+ } as ReturnType< typeof useAuthUser > );
+
+ render( );
+
+ expect( screen.getByRole( 'button', { name: 'Open account settings' } ) ).toHaveAttribute(
+ 'aria-current',
+ 'page'
+ );
+ } );
+
+ it( 'keeps settings reachable when signed out', () => {
+ useAuthUserMock.mockReturnValue( { data: undefined } as ReturnType< typeof useAuthUser > );
+
+ render( );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Settings' } ) );
+
+ expect( navigate ).toHaveBeenCalledWith( {
+ to: '/settings',
+ } );
+ expect( screen.queryByRole( 'button', { name: 'Appearance' } ) ).not.toBeInTheDocument();
+ } );
+} );
diff --git a/apps/ui/src/components/user-menu/index.tsx b/apps/ui/src/components/user-menu/index.tsx
index c2c27d2006..541e5bab3e 100644
--- a/apps/ui/src/components/user-menu/index.tsx
+++ b/apps/ui/src/components/user-menu/index.tsx
@@ -1,74 +1,46 @@
-import { useNavigate } from '@tanstack/react-router';
+import { useNavigate, useRouterState } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { cog } from '@wordpress/icons';
import { IconButton } from '@wordpress/ui';
+import { clsx } from 'clsx';
import { Gravatar } from '@/components/gravatar';
-import * as Menu from '@/components/menu';
import { SidebarButton } from '@/components/sidebar-button';
-import { useConnector } from '@/data/core';
-import { useAuthUser, useLogin, useLogout } from '@/data/queries/use-auth-user';
-import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
+import { useAuthUser, useLogin } from '@/data/queries/use-auth-user';
+import { useUserPreferences } from '@/data/queries/use-user-preferences';
import { usePrefersColorScheme } from '@/hooks/use-prefers-color-scheme';
-import { moonIcon, sunIcon } from '@/lib/icons';
import styles from './style.module.css';
-import type { ColorScheme } from '@/data/core';
-
-const WPCOM_PROFILE_URL = 'https://wordpress.com/me';
-const DOCS_URL = 'https://developer.wordpress.com/docs/developer-tools/studio/';
-const REPORT_ISSUE_URL = 'https://github.com/Automattic/studio/issues/new/choose';
export function UserMenu() {
- const connector = useConnector();
const { data: user } = useAuthUser();
const { data: preferences } = useUserPreferences();
- const savePreferences = useSaveUserPreferences();
const login = useLogin();
- const logout = useLogout();
const navigate = useNavigate();
+ const settingsActive = useRouterState( {
+ select: ( state ) => state.location.pathname === '/settings',
+ } );
const effectiveScheme = usePrefersColorScheme();
const savedScheme = preferences?.colorScheme;
- const currentScheme: ColorScheme = savedScheme ?? 'system';
const themeIsDark =
savedScheme === 'dark' || ( savedScheme !== 'light' && effectiveScheme === 'dark' );
- const openLink = ( url: string ) => {
- void connector.openExternalUrl( url );
+ const openAccountSettings = () => {
+ void navigate( { to: '/settings' } );
};
return (
{ user ? (
-
-
-
- { user.displayName }
-
- }
- />
-
-
- { user.email }
-
- void navigate( { to: '/settings' } ) }>
- { __( 'Settings' ) }
-
- openLink( WPCOM_PROFILE_URL ) }>
- { __( 'Edit WordPress.com profile' ) }
-
- openLink( DOCS_URL ) }>
- { __( 'Documentation' ) }
-
- openLink( REPORT_ISSUE_URL ) }>
- { __( 'Report an issue' ) }
-
-
- logout.mutate() }>{ __( 'Log out' ) }
-
-
+
+
+ { user.displayName }
+
) : (
login.mutate() }>
{ __( 'Log in with WordPress.com' ) }
@@ -81,38 +53,16 @@ export function UserMenu() {
variant="minimal"
tone="neutral"
size="small"
- className={ styles.settingsButton }
icon={ cog }
label={ __( 'Settings' ) }
- onClick={ () => void navigate( { to: '/settings' } ) }
+ className={ clsx(
+ styles.settingsButton,
+ settingsActive && styles.settingsButtonActive
+ ) }
+ aria-current={ settingsActive ? 'page' : undefined }
+ onClick={ openAccountSettings }
/>
) : null }
-
-
- }
- />
-
-
- savePreferences.mutate( { colorScheme: value as ColorScheme } )
- }
- >
- { __( 'System' ) }
- { __( 'Light' ) }
- { __( 'Dark' ) }
-
-
-
);
diff --git a/apps/ui/src/components/user-menu/style.module.css b/apps/ui/src/components/user-menu/style.module.css
index 4b65ae0073..58f77be27c 100644
--- a/apps/ui/src/components/user-menu/style.module.css
+++ b/apps/ui/src/components/user-menu/style.module.css
@@ -1,17 +1,20 @@
/* Pinning to the sidebar bottom is handled by the layout's
`sidebarFooter` wrapper. */
.root {
+ --user-menu-row-height: 34px;
--user-menu-leading-offset: calc(
var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm)
);
--user-menu-trailing-offset: calc(
- var(--wpds-dimension-padding-2xl) - var(--wpds-dimension-padding-xs)
+ var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-xs)
);
+ --user-menu-row-background-hover: var(--sidebar-row-background-hover);
+ --user-menu-row-background-active: var(--sidebar-row-background-active);
/* SidebarButton adds `sm` inline padding; this offset puts the gravatar's
left edge on the same column as the site icons. */
padding: var(--wpds-dimension-padding-sm) var(--user-menu-trailing-offset)
- var(--wpds-dimension-padding-xl) var(--user-menu-leading-offset);
+ var(--wpds-dimension-padding-md) var(--user-menu-leading-offset);
}
.row {
@@ -21,31 +24,42 @@
}
.userName,
-.themeToggle,
.settingsButton {
color: var(--wpds-color-fg-content-neutral-weak);
}
-.themeToggle {
- margin-inline-end: calc(-1 * var(--wpds-dimension-padding-sm));
-}
-
.row:hover .userName,
-.row:hover .themeToggle,
.row:hover .settingsButton,
.row:focus-within .userName,
-.row:focus-within .themeToggle,
.row:focus-within .settingsButton {
color: var(--wpds-color-fg-content-neutral);
}
.userTrigger {
+ --wp-ui-button-height: var(--user-menu-row-height);
--user-menu-avatar-offset: 2px;
+ box-sizing: border-box;
flex: 1;
+ height: var(--user-menu-row-height);
font-weight: var(--wpds-typography-font-weight-regular);
gap: calc(var(--wpds-dimension-gap-sm) + var(--user-menu-avatar-offset));
+ padding-block: var(--wpds-dimension-padding-xs);
padding-inline-start: calc(var(--wpds-dimension-padding-sm) - var(--user-menu-avatar-offset));
+ border-radius: 6px;
+}
+
+.userTrigger:is(:hover, :focus-visible) {
+ background-color: var(--user-menu-row-background-hover) !important;
+}
+
+.userTriggerActive {
+ background-color: var(--user-menu-row-background-active) !important;
+}
+
+.userTriggerActive .userName {
+ color: var(--wpds-color-fg-content-neutral);
+ font-weight: var(--wpds-typography-font-weight-medium);
}
.userName {
@@ -56,18 +70,23 @@
}
.loginButton {
+ --wp-ui-button-height: var(--user-menu-row-height);
+
+ box-sizing: border-box;
flex: 1;
+ height: var(--user-menu-row-height);
+ border-radius: 6px;
}
-.popup {
- min-width: 220px;
+.settingsButton {
+ --wp-ui-button-height: var(--user-menu-row-height);
+
+ box-sizing: border-box;
+ height: var(--user-menu-row-height);
+ border-radius: 6px;
}
-.email {
- padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm);
- font-size: var(--wpds-typography-font-size-xs);
- color: var(--wpds-color-fg-content-neutral-weak);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+.settingsButtonActive {
+ background-color: var(--user-menu-row-background-active) !important;
+ color: var(--wpds-color-fg-content-neutral);
}
diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts
index c2b70a89b3..603ff70090 100644
--- a/apps/ui/src/data/core/connectors/hosted/index.ts
+++ b/apps/ui/src/data/core/connectors/hosted/index.ts
@@ -1,4 +1,5 @@
import { fetchStudioBlueprints } from '@studio/common/lib/studio-blueprints-api';
+import { __ } from '@wordpress/i18n';
import type {
ActiveAgentRun,
AiSessionPlacementUpdatedEvent,
@@ -9,7 +10,9 @@ import type {
InstalledApps,
LoadedAiSession,
SiteDetails,
+ SkillStatus,
Snapshot,
+ SnapshotUsage,
SyncSite,
UserPreferences,
} from '../../types';
@@ -221,6 +224,16 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
async getSnapshots(): Promise< Snapshot[] > {
return [];
},
+ async getSnapshotUsage(): Promise< SnapshotUsage | null > {
+ return {
+ siteCount: 0,
+ siteLimit: 10,
+ siteCreationBlocked: false,
+ };
+ },
+ async deleteAllSnapshots() {
+ // No-op: hosted mode does not create WordPress.com preview sites.
+ },
async publishPreviewSite(): Promise< { url: string } > {
throw new WebUnsupportedError( 'publishPreviewSite' );
},
@@ -316,11 +329,32 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
terminal: null,
colorScheme: 'system',
locale: undefined,
+ defaultSiteDirectory: '',
+ studioCliInstalled: false,
};
},
async setUserPreferences() {
// No-op: preferences aren't persisted in the browser yet.
},
+ async previewColorScheme() {
+ // No-op: the hosted UI follows the browser theme.
+ },
+ async selectDefaultSiteDirectory() {
+ return null;
+ },
+ async getAppGlobals() {
+ return {
+ platform: 'browser',
+ appName: 'WordPress Studio',
+ appVersion: '',
+ arm64Translation: false,
+ isWindowsStore: false,
+ enableAgenticUi: true,
+ };
+ },
+ onUserSettings() {
+ return () => {};
+ },
async getInstalledApps(): Promise< InstalledApps > {
return {} as InstalledApps;
},
@@ -352,6 +386,22 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ):
const target = new URL( relativeUrl || '/', findSiteUrl( sites, siteId ) ).toString();
window.open( target, '_blank', 'noopener,noreferrer' );
},
+ async confirmDeleteAllPreviewSites() {
+ return window.confirm(
+ __(
+ 'All preview sites that exist for your WordPress.com account, along with all posts, pages, comments, and media, will be lost.'
+ )
+ );
+ },
+ async getWordPressSkillsStatusAllSites(): Promise< SkillStatus[] > {
+ return [];
+ },
+ async installWordPressSkillToAllSites() {
+ // No-op: hosted mode does not install local WordPress skills.
+ },
+ async removeWordPressSkillFromAllSites() {
+ // No-op: hosted mode does not install local WordPress skills.
+ },
// Window chrome — no traffic lights in a browser tab.
async isFullscreen() {
diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts
index 69ecd944af..3c14e3d43b 100644
--- a/apps/ui/src/data/core/connectors/ipc/index.ts
+++ b/apps/ui/src/data/core/connectors/ipc/index.ts
@@ -15,13 +15,17 @@ import type {
ProposedSitePath,
SelectedSiteFolder,
SiteDetails,
+ SkillStatus,
Snapshot,
+ SnapshotUsage,
SupportedEditor,
SupportedTerminal,
SyncSite,
+ UserSettingsEventTab,
UserPreferences,
} from '../../types';
import type { AgentRunEvent } from '@studio/common/ai/agent-events';
+import type { StoredAuthToken } from '@studio/common/lib/auth-token-schema';
function generateBackupFilename( siteName: string ): string {
const now = new Date();
@@ -39,6 +43,25 @@ type ExportRequest = {
phpVersion: string;
};
+function parseSnapshotUsage( response: unknown ): SnapshotUsage {
+ if ( ! response || typeof response !== 'object' ) {
+ throw new Error( 'Invalid snapshot usage response.' );
+ }
+ const record = response as Record< string, unknown >;
+ if (
+ typeof record.site_count !== 'number' ||
+ typeof record.site_limit !== 'number' ||
+ typeof record.site_creation_blocked !== 'boolean'
+ ) {
+ throw new Error( 'Invalid snapshot usage response.' );
+ }
+ return {
+ siteCount: record.site_count,
+ siteLimit: record.site_limit,
+ siteCreationBlocked: record.site_creation_blocked,
+ };
+}
+
// Runs an export IPC call and surfaces the outcome through the same
// main-process notification channels the legacy renderer uses: a native
// success notification on completion, or the error message box (with a
@@ -469,6 +492,29 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.fetchSnapshots() ) as Snapshot[];
},
+ async getSnapshotUsage(): Promise< SnapshotUsage | null > {
+ const token = ( await ipcApi.getAuthenticationToken() ) as StoredAuthToken | null;
+ if ( ! token ) {
+ return null;
+ }
+ const response = await fetch(
+ 'https://public-api.wordpress.com/wpcom/v2/jurassic-ninja/usage',
+ {
+ headers: {
+ Authorization: `Bearer ${ token.accessToken }`,
+ },
+ }
+ );
+ if ( ! response.ok ) {
+ throw new Error( `Failed to fetch snapshot usage: ${ response.status }` );
+ }
+ return parseSnapshotUsage( await response.json() );
+ },
+
+ async deleteAllSnapshots(): Promise< void > {
+ await ipcApi.deleteAllSnapshots();
+ },
+
async publishPreviewSite( siteId, existingHostname ): Promise< { url: string } > {
const siteFolder = await resolveSiteFolder( siteId );
// Reuses the desktop app's `createSnapshot`/`updateSnapshot` IPC
@@ -623,18 +669,23 @@ export function createIpcConnector(): Connector {
// per field; we fan out in parallel here so the UI can work with a
// single query/mutation pair.
async getUserPreferences(): Promise< UserPreferences > {
- const [ editor, terminal, colorScheme, locale ] = ( await Promise.all( [
- ipcApi.getUserEditor(),
- ipcApi.getUserTerminal(),
- ipcApi.getColorScheme(),
- ipcApi.getUserLocale(),
- ] ) ) as [
- SupportedEditor | null,
- SupportedTerminal | null,
- ColorScheme,
- string | undefined,
- ];
- return { editor, terminal, colorScheme, locale };
+ const [ editor, terminal, colorScheme, locale, defaultSiteDirectory, studioCliInstalled ] =
+ ( await Promise.all( [
+ ipcApi.getUserEditor(),
+ ipcApi.getUserTerminal(),
+ ipcApi.getColorScheme(),
+ ipcApi.getUserLocale(),
+ ipcApi.getDefaultSiteDirectory(),
+ ipcApi.isStudioCliInstalled(),
+ ] ) ) as [
+ SupportedEditor | null,
+ SupportedTerminal | null,
+ ColorScheme,
+ string | undefined,
+ string,
+ boolean,
+ ];
+ return { editor, terminal, colorScheme, locale, defaultSiteDirectory, studioCliInstalled };
},
async setUserPreferences( partial ): Promise< void > {
@@ -651,9 +702,44 @@ export function createIpcConnector(): Connector {
if ( 'locale' in partial && partial.locale ) {
writes.push( ipcApi.saveUserLocale( partial.locale ) );
}
+ if ( 'defaultSiteDirectory' in partial && partial.defaultSiteDirectory ) {
+ writes.push( ipcApi.saveDefaultSiteDirectory( partial.defaultSiteDirectory ) );
+ }
+ if ( 'studioCliInstalled' in partial && typeof partial.studioCliInstalled === 'boolean' ) {
+ writes.push(
+ partial.studioCliInstalled ? ipcApi.installStudioCli() : ipcApi.uninstallStudioCli()
+ );
+ }
await Promise.all( writes );
},
+ async previewColorScheme( colorScheme ): Promise< void > {
+ await ipcApi.previewColorScheme( colorScheme );
+ },
+
+ async selectDefaultSiteDirectory( defaultPath ): Promise< string | null > {
+ const response = ( await ipcApi.showOpenFolderDialog(
+ __( 'Select default site directory' ),
+ defaultPath
+ ) ) as { path?: string } | string | null;
+ if ( typeof response === 'string' ) {
+ return response || null;
+ }
+ return response?.path ?? null;
+ },
+
+ async getAppGlobals() {
+ return ipcApi.getAppGlobals();
+ },
+
+ onUserSettings( listener ) {
+ return ipcListener.subscribe(
+ 'user-settings',
+ ( _event: unknown, payload: { tabName?: UserSettingsEventTab } ) =>
+ listener( payload?.tabName )
+ );
+ },
+
async getInstalledApps(): Promise< InstalledApps > {
return ( await ipcApi.getInstalledAppsAndTerminals() ) as InstalledApps;
},
@@ -694,6 +780,33 @@ export function createIpcConnector(): Connector {
await ipcApi.openSiteURL( siteId, relativeUrl, options );
},
+ async confirmDeleteAllPreviewSites(): Promise< boolean > {
+ const CANCEL_BUTTON_INDEX = 0;
+ const DELETE_BUTTON_INDEX = 1;
+ const { response } = ( await ipcApi.showMessageBox( {
+ type: 'warning',
+ message: __( 'Delete all preview sites' ),
+ detail: __(
+ 'All preview sites that exist for your WordPress.com account, along with all posts, pages, comments, and media, will be lost.'
+ ),
+ buttons: [ __( 'Cancel' ), __( 'Delete all' ) ],
+ cancelId: CANCEL_BUTTON_INDEX,
+ } ) ) as { response: number };
+ return response === DELETE_BUTTON_INDEX;
+ },
+
+ async getWordPressSkillsStatusAllSites(): Promise< SkillStatus[] > {
+ return ( await ipcApi.getWordPressSkillsStatusAllSites() ) as SkillStatus[];
+ },
+
+ async installWordPressSkillToAllSites( skillId ): Promise< void > {
+ await ipcApi.installWordPressSkillsToAllSites( { skillId } );
+ },
+
+ async removeWordPressSkillFromAllSites( skillId ): Promise< void > {
+ await ipcApi.removeWordPressSkillFromAllSites( skillId );
+ },
+
// Window state
async isFullscreen(): Promise< boolean > {
return ipcApi.isFullscreen();
diff --git a/apps/ui/src/data/core/index.ts b/apps/ui/src/data/core/index.ts
index 78c93072e5..808672b42e 100644
--- a/apps/ui/src/data/core/index.ts
+++ b/apps/ui/src/data/core/index.ts
@@ -16,7 +16,9 @@ export type {
SelectedSiteFolder,
SessionEntry,
SiteDetails,
+ SkillStatus,
Snapshot,
+ SnapshotUsage,
StudioAgentQuestionData,
StudioChatFileAttachment,
StudioChatImage,
@@ -33,6 +35,7 @@ export type {
SupportedLocale,
SupportedTerminal,
SyncSite,
+ UserSettingsEventTab,
UserPreferences,
WritableUserPreferences,
} from './types';
diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts
index 7238a801c5..e290d5716b 100644
--- a/apps/ui/src/data/core/types.ts
+++ b/apps/ui/src/data/core/types.ts
@@ -175,6 +175,8 @@ export interface Connector {
// Preview snapshots (WordPress.com hosted previews of local sites)
getSnapshots(): Promise< Snapshot[] >;
+ getSnapshotUsage(): Promise< SnapshotUsage | null >;
+ deleteAllSnapshots(): Promise< void >;
// Creates a new preview snapshot for the given site, or refreshes the
// existing one when `existingHostname` is supplied. Resolves with the
// final preview URL when the CLI command completes.
@@ -265,6 +267,10 @@ export interface Connector {
// single query + mutation to work with.
getUserPreferences(): Promise< UserPreferences >;
setUserPreferences( partial: Partial< WritableUserPreferences > ): Promise< void >;
+ previewColorScheme( colorScheme: ColorScheme ): Promise< void >;
+ selectDefaultSiteDirectory( defaultPath: string ): Promise< string | null >;
+ getAppGlobals(): Promise< AppGlobals >;
+ onUserSettings( listener: ( tabName?: UserSettingsEventTab ) => void ): () => void;
// Apps detected on disk (editors + terminals). Options in the preferences
// form are filtered against this so users can't pick something that isn't
@@ -294,6 +300,12 @@ export interface Connector {
relativeUrl?: string,
options?: { autoLogin?: boolean }
): Promise< void >;
+ confirmDeleteAllPreviewSites(): Promise< boolean >;
+
+ // WordPress agent skills applied to all existing and future sites.
+ getWordPressSkillsStatusAllSites(): Promise< SkillStatus[] >;
+ installWordPressSkillToAllSites( skillId: string ): Promise< void >;
+ removeWordPressSkillFromAllSites( skillId: string ): Promise< void >;
// Window state (macOS fullscreen hides traffic lights, so the UI needs
// to reclaim the space we normally leave for them).
@@ -312,6 +324,19 @@ export interface Connector {
onToggleSidebar( listener: () => void ): () => void;
}
+export interface SkillStatus {
+ id: string;
+ displayName: string;
+ description: string;
+ installed: boolean;
+}
+
+export interface SnapshotUsage {
+ siteCount: number;
+ siteLimit: number;
+ siteCreationBlocked: boolean;
+}
+
export type ColorScheme = 'system' | 'light' | 'dark';
export interface UserPreferences {
@@ -319,6 +344,8 @@ export interface UserPreferences {
terminal: SupportedTerminal | null;
colorScheme: ColorScheme;
locale: string | undefined;
+ defaultSiteDirectory: string;
+ studioCliInstalled: boolean;
}
// Subset of UserPreferences that callers can actually mutate. `locale` is
@@ -328,6 +355,17 @@ export type WritableUserPreferences = Omit< UserPreferences, 'locale' > & {
locale: SupportedLocale;
};
+export type UserSettingsEventTab = 'general' | 'account' | 'usage' | 'skills' | 'mcp';
+
+export interface AppGlobals {
+ platform: string;
+ appName: string;
+ appVersion: string;
+ arm64Translation: boolean;
+ isWindowsStore: boolean;
+ enableAgenticUi: boolean;
+}
+
export interface FeaturedBlueprint {
slug: string;
title: string;
diff --git a/apps/ui/src/data/queries/use-app-globals.ts b/apps/ui/src/data/queries/use-app-globals.ts
new file mode 100644
index 0000000000..3220aed9ab
--- /dev/null
+++ b/apps/ui/src/data/queries/use-app-globals.ts
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+import { useConnector } from '@/data/core';
+
+export const APP_GLOBALS_QUERY_KEY = [ 'app-globals' ] as const;
+
+export function useAppGlobals() {
+ const connector = useConnector();
+ return useQuery( {
+ queryKey: APP_GLOBALS_QUERY_KEY,
+ queryFn: () => connector.getAppGlobals(),
+ staleTime: Infinity,
+ } );
+}
diff --git a/apps/ui/src/data/queries/use-snapshots.ts b/apps/ui/src/data/queries/use-snapshots.ts
index f2eb149542..4459794ed1 100644
--- a/apps/ui/src/data/queries/use-snapshots.ts
+++ b/apps/ui/src/data/queries/use-snapshots.ts
@@ -1,12 +1,55 @@
-import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useConnector } from '@/data/core';
+import type { SnapshotUsage } from '@/data/core';
export const SNAPSHOTS_QUERY_KEY = [ 'snapshots' ] as const;
+export const SNAPSHOT_USAGE_QUERY_KEY = [ 'snapshot-usage' ] as const;
-export function useSnapshots() {
+function getSnapshotsQueryKey( userId?: number ) {
+ return userId === undefined
+ ? SNAPSHOTS_QUERY_KEY
+ : ( [ ...SNAPSHOTS_QUERY_KEY, userId ] as const );
+}
+
+function getSnapshotUsageQueryKey( userId?: number ) {
+ return userId === undefined
+ ? SNAPSHOT_USAGE_QUERY_KEY
+ : ( [ ...SNAPSHOT_USAGE_QUERY_KEY, userId ] as const );
+}
+
+export function useSnapshots( userId?: number ) {
const connector = useConnector();
return useQuery( {
- queryKey: SNAPSHOTS_QUERY_KEY,
+ queryKey: getSnapshotsQueryKey( userId ),
queryFn: () => connector.getSnapshots(),
+ select: ( snapshots ) =>
+ userId === undefined
+ ? snapshots
+ : snapshots.filter( ( snapshot ) => snapshot.userId === userId ),
+ } );
+}
+
+export function useSnapshotUsage( userId?: number ) {
+ const connector = useConnector();
+ return useQuery( {
+ queryKey: getSnapshotUsageQueryKey( userId ),
+ queryFn: () => connector.getSnapshotUsage(),
+ meta: { persist: false },
+ } );
+}
+
+export function useDeleteAllSnapshots( userId?: number ) {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: () => connector.deleteAllSnapshots(),
+ onSuccess: () => {
+ void queryClient.invalidateQueries( { queryKey: SNAPSHOTS_QUERY_KEY } );
+ void queryClient.invalidateQueries( { queryKey: SNAPSHOT_USAGE_QUERY_KEY } );
+ queryClient.setQueryData< SnapshotUsage | null >(
+ getSnapshotUsageQueryKey( userId ),
+ ( current ) => ( current ? { ...current, siteCount: 0 } : current )
+ );
+ },
} );
}
diff --git a/apps/ui/src/data/queries/use-wordpress-skills.ts b/apps/ui/src/data/queries/use-wordpress-skills.ts
new file mode 100644
index 0000000000..3f55c86f74
--- /dev/null
+++ b/apps/ui/src/data/queries/use-wordpress-skills.ts
@@ -0,0 +1,49 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useConnector } from '@/data/core';
+
+export const WORDPRESS_SKILLS_QUERY_KEY = [ 'wordpress-skills' ] as const;
+
+export function useWordPressSkills() {
+ const connector = useConnector();
+ return useQuery( {
+ queryKey: WORDPRESS_SKILLS_QUERY_KEY,
+ queryFn: () => connector.getWordPressSkillsStatusAllSites(),
+ } );
+}
+
+export function useInstallWordPressSkill() {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: ( skillId: string ) => connector.installWordPressSkillToAllSites( skillId ),
+ onSuccess: () => {
+ void queryClient.invalidateQueries( { queryKey: WORDPRESS_SKILLS_QUERY_KEY } );
+ },
+ } );
+}
+
+export function useInstallAllWordPressSkills() {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: async ( skillIds: string[] ) => {
+ for ( const skillId of skillIds ) {
+ await connector.installWordPressSkillToAllSites( skillId );
+ }
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries( { queryKey: WORDPRESS_SKILLS_QUERY_KEY } );
+ },
+ } );
+}
+
+export function useRemoveWordPressSkill() {
+ const connector = useConnector();
+ const queryClient = useQueryClient();
+ return useMutation( {
+ mutationFn: ( skillId: string ) => connector.removeWordPressSkillFromAllSites( skillId ),
+ onSuccess: () => {
+ void queryClient.invalidateQueries( { queryKey: WORDPRESS_SKILLS_QUERY_KEY } );
+ },
+ } );
+}
diff --git a/apps/ui/src/hooks/use-offline.ts b/apps/ui/src/hooks/use-offline.ts
new file mode 100644
index 0000000000..da363f4c1e
--- /dev/null
+++ b/apps/ui/src/hooks/use-offline.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react';
+
+export function useOffline() {
+ const [ isOffline, setIsOffline ] = useState(
+ () => typeof navigator !== 'undefined' && ! navigator.onLine
+ );
+
+ useEffect( () => {
+ const handleOnline = () => setIsOffline( false );
+ const handleOffline = () => setIsOffline( true );
+
+ window.addEventListener( 'online', handleOnline );
+ window.addEventListener( 'offline', handleOffline );
+
+ return () => {
+ window.removeEventListener( 'online', handleOnline );
+ window.removeEventListener( 'offline', handleOffline );
+ };
+ }, [] );
+
+ return isOffline;
+}
diff --git a/apps/ui/src/index.css b/apps/ui/src/index.css
index 5ac9aff541..f339a9df0f 100644
--- a/apps/ui/src/index.css
+++ b/apps/ui/src/index.css
@@ -8,6 +8,7 @@
above dialogs so help text on controls inside a modal still surfaces
above the containing overlay. */
--wp-ui-dialog-z-index: 700;
+ --wp-ui-select-z-index: 750;
--wp-ui-tooltip-z-index: 800;
}
@@ -114,6 +115,17 @@ a:focus:not( :focus-visible ),
height: 16px;
}
+/* @wordpress/ui SelectControl and Autocomplete popup items use the brand
+ weak-active fill for highlighted rows. In Studio's dark chrome that reads
+ too saturated; the selected item is already indicated with a checkmark, so
+ use the neutral interactive highlight instead. */
+[data-wpds-theme-provider-id] [role='listbox'] [role='option'][data-highlighted]:not(
+ [aria-disabled='true']
+ ) {
+ background-color: var( --wpds-color-bg-interactive-neutral-weak-active );
+ color: var( --wpds-color-fg-interactive-neutral );
+}
+
/* @wordpress/components' ComboboxControl (used by DataForm's adaptive
picker once a field has 10+ elements) leaves its wrapper without a
background, so the body surface bleeds through around the inner input
diff --git a/apps/ui/src/lib/docs-links.ts b/apps/ui/src/lib/docs-links.ts
index 67ae38523d..66b8aa8362 100644
--- a/apps/ui/src/lib/docs-links.ts
+++ b/apps/ui/src/lib/docs-links.ts
@@ -15,6 +15,16 @@ const DOCS_LINKS = {
docsSslInStudio: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/',
},
+ docsCli: {
+ en: 'https://developer.wordpress.com/docs/developer-tools/studio/cli/',
+ es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/cli/',
+ },
+ docsMcp: {
+ en: 'https://developer.wordpress.com/docs/developer-tools/studio/mcp-on-studio/',
+ },
+ docsSkills: {
+ en: 'https://developer.wordpress.com/docs/developer-tools/studio/agent-skills-wordpress-studio/',
+ },
} as const satisfies Record< string, TranslatedLink >;
export type DocsLinkKey = keyof typeof DOCS_LINKS;
diff --git a/apps/ui/src/ui-classic/router/layout-root/index.tsx b/apps/ui/src/ui-classic/router/layout-root/index.tsx
index cf750c6646..82b5a5c803 100644
--- a/apps/ui/src/ui-classic/router/layout-root/index.tsx
+++ b/apps/ui/src/ui-classic/router/layout-root/index.tsx
@@ -1,5 +1,8 @@
-import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
-import type { Connector } from '@/data/core';
+import { createRootRouteWithContext, Outlet, useNavigate } from '@tanstack/react-router';
+import { useEffect } from 'react';
+import { normalizeSettingsTab } from '@/components/settings-view';
+import { useConnector } from '@/data/core';
+import type { Connector, UserSettingsEventTab } from '@/data/core';
import type { QueryClient } from '@tanstack/react-query';
export interface RouterContext {
@@ -7,6 +10,30 @@ export interface RouterContext {
connector: Connector;
}
+function getSettingsSearchFromEvent( tabName: UserSettingsEventTab | undefined ) {
+ if ( ! tabName ) {
+ return {};
+ }
+ const tab = normalizeSettingsTab( tabName );
+ return tab === 'preferences' ? {} : { tab };
+}
+
+function RootLayout() {
+ const connector = useConnector();
+ const navigate = useNavigate();
+
+ useEffect( () => {
+ return connector.onUserSettings( ( tabName ) => {
+ void navigate( {
+ to: '/settings',
+ search: getSettingsSearchFromEvent( tabName ),
+ } );
+ } );
+ }, [ connector, navigate ] );
+
+ return ;
+}
+
export const rootRoute = createRootRouteWithContext< RouterContext >()( {
- component: () => ,
+ component: RootLayout,
} );
diff --git a/apps/ui/src/ui-classic/router/route-settings/index.tsx b/apps/ui/src/ui-classic/router/route-settings/index.tsx
index 5db2d25bb5..809f6c8dc1 100644
--- a/apps/ui/src/ui-classic/router/route-settings/index.tsx
+++ b/apps/ui/src/ui-classic/router/route-settings/index.tsx
@@ -1,11 +1,11 @@
import { createRoute, useNavigate } from '@tanstack/react-router';
-import { isSettingsTab, SettingsView } from '@/components/settings-view';
+import { normalizeSettingsTab, SettingsView } from '@/components/settings-view';
import { dashboardLayoutRoute } from '../layout-dashboard';
import type { SettingsTabId } from '@/components/settings-view';
interface SettingsSearch {
// Tab selection is a `search` param so opening the route defaults to
- // preferences and deep-links like `?tab=account` stay human-readable.
+ // preferences and deep-links like `?tab=usage` stay human-readable.
// Mirrors the shape used by the site-settings route.
tab?: SettingsTabId;
}
@@ -20,7 +20,7 @@ function SettingsPage() {
onTabChange={ ( next ) =>
void navigate( {
to: '/settings',
- search: { tab: next },
+ search: next === 'preferences' ? {} : { tab: next },
replace: true,
} )
}
@@ -33,8 +33,8 @@ export const settingsRoute = createRoute( {
path: '/settings',
validateSearch: ( search: Record< string, unknown > ): SettingsSearch => {
const value = search.tab;
- if ( typeof value === 'string' && isSettingsTab( value ) ) {
- return { tab: value };
+ if ( typeof value === 'string' ) {
+ return { tab: normalizeSettingsTab( value ) };
}
return {};
},