From 322eac48a463d2d9934f945d079882c8567af8c4 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Thu, 23 Jul 2026 21:23:57 -0400 Subject: [PATCH 1/4] Redesign global app settings: unified card system, account sidebar, consolidated tabs Co-Authored-By: Claude Opus 4.8 (1M context) --- .../settings-view/account-section.test.tsx | 27 +- .../settings-view/account-section.tsx | 133 +- .../settings-view/ai-panel.test.tsx | 59 +- .../src/components/settings-view/ai-panel.tsx | 71 +- .../components/settings-view/index.test.tsx | 33 +- .../ui/src/components/settings-view/index.tsx | 212 ++-- .../settings-view/keyboard-panel.tsx | 25 +- .../settings-view/mcp-panel.test.tsx | 8 +- .../components/settings-view/mcp-panel.tsx | 40 +- .../settings-view/settings-preview.tsx | 278 +++++ .../components/settings-view/skills-panel.tsx | 106 +- .../settings-view/studio-cli-section.tsx | 62 +- .../settings-view/studio-code-panel.test.tsx | 50 +- .../settings-view/studio-code-panel.tsx | 108 +- .../components/settings-view/style.module.css | 1103 +++++++++-------- .../settings-view/usage-panel.test.tsx | 266 ++-- .../components/settings-view/usage-panel.tsx | 293 ++--- packages/common/lib/studio-assistant-quota.ts | 9 + 18 files changed, 1701 insertions(+), 1182 deletions(-) create mode 100644 apps/ui/src/components/settings-view/settings-preview.tsx diff --git a/apps/ui/src/components/settings-view/account-section.test.tsx b/apps/ui/src/components/settings-view/account-section.test.tsx index f027a934de..c92f071b9d 100644 --- a/apps/ui/src/components/settings-view/account-section.test.tsx +++ b/apps/ui/src/components/settings-view/account-section.test.tsx @@ -53,6 +53,11 @@ vi.mock( '@/hooks/use-color-scheme', () => ( { useColorScheme: () => 'light', } ) ); +vi.mock( './usage-panel', () => ( { + AiCreditsSection: () =>
, + PreviewUsageSection: () =>
, +} ) ); + const useConnectorMock = vi.mocked( useConnector ); const useAuthUserMock = vi.mocked( useAuthUser ); const useLoginMock = vi.mocked( useLogin ); @@ -90,19 +95,29 @@ describe( 'AccountSection', () => { expect( logoutMutate ).toHaveBeenCalledTimes( 1 ); } ); - it( 'prompts signed-out users to log in', () => { + it( 'shows preview-site usage only for a signed-in user', () => { + const { rerender } = render( ); + expect( screen.getByTestId( 'preview-usage-section' ) ).toBeInTheDocument(); + + useAuthUserMock.mockReturnValue( { data: null, isLoading: false } as never ); + rerender( ); + expect( screen.queryByTestId( 'preview-usage-section' ) ).not.toBeInTheDocument(); + } ); + + it( 'prompts signed-out users with the overview sign-in pitch', () => { useAuthUserMock.mockReturnValue( { data: null, isLoading: false } as never ); render( ); - expect( screen.getByText( 'WordPress.com account' ) ).toBeInTheDocument(); expect( - screen.getByText( 'Log in to use AI features and synchronize with live and preview sites.' ) + screen.getByRole( 'heading', { name: 'Let Studio code it for you' } ) ).toBeInTheDocument(); + expect( screen.getByText( /An AI powered WordPress expert/ ) ).toBeInTheDocument(); expect( screen.queryByTestId( 'gravatar' ) ).not.toBeInTheDocument(); + expect( screen.queryByTestId( 'ai-credits-section' ) ).not.toBeInTheDocument(); expect( screen.queryByRole( 'button', { name: 'Log out' } ) ).not.toBeInTheDocument(); - fireEvent.click( screen.getByRole( 'button', { name: 'Log in' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Log in with WordPress.com' } ) ); expect( loginMutate ).toHaveBeenCalledTimes( 1 ); } ); @@ -110,7 +125,7 @@ describe( 'AccountSection', () => { it( 'opens docs and issue links through the connector', () => { render( ); - fireEvent.click( screen.getByRole( 'button', { name: 'Docs' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Documentation' } ) ); expect( openExternalUrl ).toHaveBeenCalledWith( 'https://developer.wordpress.com/docs/developer-tools/studio/' @@ -128,7 +143,7 @@ describe( 'AccountSection', () => { render( ); - fireEvent.click( screen.getByRole( 'button', { name: 'Docs' } ) ); + fireEvent.click( screen.getByRole( 'button', { name: 'Documentation' } ) ); expect( openExternalUrl ).toHaveBeenCalledWith( 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/' diff --git a/apps/ui/src/components/settings-view/account-section.tsx b/apps/ui/src/components/settings-view/account-section.tsx index 15b59029ab..07e69f3d70 100644 --- a/apps/ui/src/components/settings-view/account-section.tsx +++ b/apps/ui/src/components/settings-view/account-section.tsx @@ -1,15 +1,17 @@ import { __ } from '@wordpress/i18n'; +import { caution, Icon, page } from '@wordpress/icons'; import { Button } from '@wordpress/ui'; -import { clsx } from 'clsx'; import { Gravatar } from '@/components/gravatar'; import { useConnector } from '@/data/core'; -import { useAuthUser, useLogin, useLogout } from '@/data/queries/use-auth-user'; +import { useLogin, useLogout } from '@/data/queries/use-auth-user'; import { useUserLocale } from '@/data/queries/use-user-locale'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { getLocalizedLink, REPORT_ISSUE_URL } from '@/lib/docs-links'; +import { usePreviewAuthUser } from './settings-preview'; import styles from './style.module.css'; +import { AiCreditsSection, PreviewUsageSection } from './usage-panel'; -function AccountHelpActions() { +function AccountHelpLinks() { const connector = useConnector(); const locale = useUserLocale(); @@ -18,86 +20,97 @@ function AccountHelpActions() { }; return ( -
- - + + + { __( 'Report an issue' ) } +
); } export function AccountSection() { - const { data: user, isLoading } = useAuthUser(); + const { data: user, isLoading } = usePreviewAuthUser(); const login = useLogin(); const logout = useLogout(); const themeIsDark = useColorScheme() === '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 ? ( - - ) : ( +
+ { user ? ( + <> +
+

{ __( 'Account' ) }

+
+ +
+ { user.displayName } +

{ user.email }

+
+
+
+ + + + + ) : ( +
+

{ __( 'Let Studio code it for you' ) }

+

+ { __( + 'An AI powered WordPress expert that can build a site, theme, or plugin, and help you share and publish.' + ) } +

- ) } -
-
+ + ) } + +
+

{ __( 'Help' ) }

+ +
+ + { user ? ( + + ) : null } +
); } diff --git a/apps/ui/src/components/settings-view/ai-panel.test.tsx b/apps/ui/src/components/settings-view/ai-panel.test.tsx index 7d90ac5f04..b66a974021 100644 --- a/apps/ui/src/components/settings-view/ai-panel.test.tsx +++ b/apps/ui/src/components/settings-view/ai-panel.test.tsx @@ -2,6 +2,7 @@ import '@testing-library/jest-dom/vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { AiPanel } from './ai-panel'; vi.mock( '@wordpress/components', () => ( { @@ -25,11 +26,28 @@ vi.mock( '@/data/core', () => ( { useConnector: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-agentic-features', () => ( { + useAgenticFeatures: vi.fn(), +} ) ); + +vi.mock( './account-section', () => ( { + AccountSection: () =>
, +} ) ); + vi.mock( './studio-code-panel', () => ( { StudioCodePanel: () =>
, } ) ); +vi.mock( './skills-panel', () => ( { + SkillsPanel: () =>
, +} ) ); + +vi.mock( '@/components/offline-banner', () => ( { + OfflineNotice: () =>
You're offline
, +} ) ); + const useConnectorMock = vi.mocked( useConnector ); +const useAgenticFeaturesMock = vi.mocked( useAgenticFeatures ); describe( 'AiPanel', () => { const disableAgenticUi = vi.fn( () => Promise.resolve() ); @@ -43,6 +61,7 @@ describe( 'AiPanel', () => { beforeEach( () => { vi.clearAllMocks(); + useAgenticFeaturesMock.mockReturnValue( { reason: null } as never ); } ); it( 'switches back to the classic UI when agentic features are toggled off', () => { @@ -59,12 +78,14 @@ describe( 'AiPanel', () => { expect( toggle ).toBeDisabled(); } ); - it( 'hides the agentic features section when the host cannot switch UIs', () => { + it( 'hides the toggle but keeps the card when the host cannot switch UIs', () => { mockConnector( false ); render( ); expect( screen.queryByRole( 'checkbox' ) ).not.toBeInTheDocument(); expect( disableAgenticUi ).not.toHaveBeenCalled(); + // The card still renders its heading, just without the toggle. + expect( screen.getByRole( 'heading', { name: 'Agentic features' } ) ).toBeInTheDocument(); } ); it( 'shows the global instructions editor alongside the agentic features toggle', () => { @@ -81,4 +102,40 @@ describe( 'AiPanel', () => { expect( screen.queryByTestId( 'studio-code-panel' ) ).not.toBeInTheDocument(); } ); + + it( 'shows no sign-in banner when signed out — the pitch lives in the account sidebar', () => { + mockConnector( true ); + useAgenticFeaturesMock.mockReturnValue( { reason: 'signed-out' } as never ); + render( ); + + expect( screen.queryByLabelText( 'Sign in to Studio' ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'status' ) ).not.toBeInTheDocument(); + } ); + + it( 'disables the agentic features toggle when signed out', () => { + mockConnector( true ); + useAgenticFeaturesMock.mockReturnValue( { reason: 'signed-out' } as never ); + render( ); + + const toggle = screen.getByRole( 'checkbox', { name: 'Agentic features' } ); + expect( toggle ).toBeDisabled(); + expect( toggle ).not.toBeChecked(); + } ); + + it( 'shows the offline notice when offline', () => { + mockConnector( true ); + useAgenticFeaturesMock.mockReturnValue( { reason: 'offline' } as never ); + render( ); + + expect( screen.getByRole( 'status' ) ).toHaveTextContent( "You're offline" ); + expect( screen.queryByLabelText( 'Sign in to Studio' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows neither banner when signed in and online', () => { + mockConnector( true ); + render( ); + + expect( screen.queryByLabelText( 'Sign in to Studio' ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'status' ) ).not.toBeInTheDocument(); + } ); } ); diff --git a/apps/ui/src/components/settings-view/ai-panel.tsx b/apps/ui/src/components/settings-view/ai-panel.tsx index 3d24640f48..1c84ee952e 100644 --- a/apps/ui/src/components/settings-view/ai-panel.tsx +++ b/apps/ui/src/components/settings-view/ai-panel.tsx @@ -2,55 +2,76 @@ import { FormToggle } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { clsx } from 'clsx'; import { useState } from 'react'; +import { OfflineNotice } from '@/components/offline-banner'; import { useConnector } from '@/data/core'; +import { AccountSection } from './account-section'; +import { usePreviewAgenticFeatures } from './settings-preview'; +import { SkillsPanel } from './skills-panel'; import { StudioCodePanel } from './studio-code-panel'; import styles from './style.module.css'; function AgenticFeaturesSection() { const connector = useConnector(); + const { reason } = usePreviewAgenticFeatures(); const [ disabling, setDisabling ] = useState( false ); - - if ( ! connector.capabilities.switchToClassicUi ) { - return null; - } + const canSwitchToClassic = connector.capabilities.switchToClassicUi; + // Agentic features need a WordPress.com account, so they read as off and + // locked until the user signs in. + const signedOut = reason === 'signed-out'; // Turning agentic features off reloads the window into the classic UI, so - // there is no enable path here — once disabled, this panel no longer exists. + // there is no enable path — the toggle only ever goes one way. Hosts that + // can't switch (e.g. the hosted browser) still get the card, just without + // the toggle. const handleDisable = () => { setDisabling( true ); connector.disableAgenticUi().catch( () => setDisabling( false ) ); }; return ( -
-
-
-

{ __( 'Agentic features' ) }

-

- { __( - 'Studio Code brings agentic, AI-powered site building to Studio. Turning it off reloads the app into the classic interface.' - ) } +

+
+
+

{ __( 'Agentic features' ) }

+

+ { canSwitchToClassic && ! signedOut + ? __( + 'Studio Code brings agentic, AI-powered site building to Studio. Turning it off reloads the app into the classic interface.' + ) + : __( 'Studio Code brings agentic, AI-powered site building to Studio.' ) }

-
- -
-
+ { canSwitchToClassic ? ( +
+ +
+ ) : null } +
); } export function AiPanel() { const connector = useConnector(); + // Offline is the one whole-tab state worth a banner here; the signed-out + // sign-in pitch and the usage meters (AI credits, preview sites) now live in + // the account sidebar. + const { reason } = usePreviewAgenticFeatures(); + return ( -
- { connector.capabilities.agentInstructions && } - +
+ +
+ { reason === 'offline' ? : null } + + { connector.capabilities.agentInstructions && } + +
); } diff --git a/apps/ui/src/components/settings-view/index.test.tsx b/apps/ui/src/components/settings-view/index.test.tsx index cf1c076da7..1db77aae3a 100644 --- a/apps/ui/src/components/settings-view/index.test.tsx +++ b/apps/ui/src/components/settings-view/index.test.tsx @@ -82,10 +82,6 @@ vi.mock( './ai-panel', () => ( { AiPanel: () =>
, } ) ); -vi.mock( './skills-panel', () => ( { - SkillsPanel: () => null, -} ) ); - vi.mock( './studio-cli-section', () => ( { StudioCliSection: () => null, } ) ); @@ -101,7 +97,7 @@ vi.mock( '@/hooks/use-color-scheme', () => ( { } ) ); vi.mock( './mcp-panel', () => ( { - McpPanel: () =>
, + McpSection: () =>
, } ) ); vi.mock( '@/data/core/query-client', () => ( { @@ -117,12 +113,6 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( { useUserPreferences: vi.fn(), } ) ); -// The mocked Tabs render every panel unconditionally; the usage panel has its -// own test file. -vi.mock( './usage-panel', () => ( { - UsagePanel: () => null, -} ) ); - vi.mock( '@/hooks/use-traffic-light-space', () => ( { useTrafficLightSpace: () => false, } ) ); @@ -221,11 +211,13 @@ describe( 'SettingsView', () => { it( 'renders the AI tab with its panel', () => { render( ); - expect( screen.getByRole( 'button', { name: 'AI' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Agent' } ) ).toBeInTheDocument(); expect( screen.getByTestId( 'ai-panel' ) ).toBeInTheDocument(); } ); - it( 'hides the AI tab when the host has no AI settings to offer', () => { + it( 'always shows the AI tab, even when the host has no AI settings to offer', () => { + // The AI tab now also holds Usage, which is shown for every host, so the + // tab is always present — the AI-specific sections gate themselves inside. useConnectorMock.mockReturnValue( { selectDefaultSiteDirectory, capabilities: { agentInstructions: false, switchToClassicUi: false }, @@ -233,19 +225,20 @@ describe( 'SettingsView', () => { render( ); - expect( screen.queryByRole( 'button', { name: 'AI' } ) ).not.toBeInTheDocument(); - expect( screen.queryByTestId( 'ai-panel' ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Agent' } ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'ai-panel' ) ).toBeInTheDocument(); } ); - it( 'recognizes the keyboard tab id', () => { - expect( isSettingsTab( 'keyboard' ) ).toBe( true ); + it( 'recognizes valid settings tab ids', () => { + expect( isSettingsTab( 'preferences' ) ).toBe( true ); + expect( isSettingsTab( 'ai' ) ).toBe( true ); + expect( isSettingsTab( 'keyboard' ) ).toBe( false ); expect( isSettingsTab( 'unknown' ) ).toBe( false ); } ); - it( 'renders keyboard shortcut sections', () => { - render( ); + it( 'renders keyboard shortcut sections inside Settings', () => { + render( ); - expect( screen.getByRole( 'button', { name: 'Keyboard' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'heading', { name: 'Composer' } ) ).toBeInTheDocument(); expect( screen.getByRole( 'heading', { name: 'Site preview' } ) ).toBeInTheDocument(); expect( screen.getByText( 'New chat' ) ).toBeInTheDocument(); diff --git a/apps/ui/src/components/settings-view/index.tsx b/apps/ui/src/components/settings-view/index.tsx index ba3806a3f1..41e4f16dfd 100644 --- a/apps/ui/src/components/settings-view/index.tsx +++ b/apps/ui/src/components/settings-view/index.tsx @@ -16,16 +16,18 @@ import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import { AccountSection } from './account-section'; import { AiPanel } from './ai-panel'; import { KeyboardPanel } from './keyboard-panel'; -import { McpPanel } from './mcp-panel'; +import { McpSection } from './mcp-panel'; import { UNSET, toPreferencesFormData, toPreferencesPatch } from './preferences'; -import { SkillsPanel } from './skills-panel'; +import { + SettingsPreviewPanel, + SettingsPreviewProvider, + SHOW_PREVIEW_CONTROLS, +} from './settings-preview'; import { StudioCliSection } from './studio-cli-section'; import styles from './style.module.css'; -import { UsagePanel } from './usage-panel'; import type { PreferencesFormData } from './preferences'; import type { ColorScheme, - Connector, InstalledApps, QuitSitesBehavior, SupportedEditor, @@ -34,7 +36,7 @@ import type { } from '@/data/core'; import type { ReactNode } from 'react'; -const SETTINGS_TABS = [ 'preferences', 'ai', 'usage', 'keyboard', 'skills', 'mcp' ] as const; +const SETTINGS_TABS = [ 'preferences', 'ai' ] as const; type TabId = ( typeof SETTINGS_TABS )[ number ]; @@ -44,12 +46,6 @@ export function isSettingsTab( value: string ): value is TabId { export type SettingsTabId = TabId; -// The AI tab holds the agentic-features toggle and the agent's global -// instructions; hosts that offer neither (the hosted browser) don't get the tab. -function hasAiSettings( capabilities: Connector[ 'capabilities' ] ): boolean { - return capabilities.switchToClassicUi || capabilities.agentInstructions; -} - // No "unset" option: the main process resolves a fallback for never-chosen // editor/terminal prefs (matching the legacy UI), so an explicit clear can't // round-trip. The select shows its placeholder when nothing is installed. @@ -98,7 +94,6 @@ const LOCALE_ELEMENTS: { value: SupportedLocale; label: string }[] = Object.entr function SettingsHeader() { // Settings renders fullscreen, so the sidebar (and its floating toggle) is // covered — only the macOS traffic lights still need clearing. - const connector = useConnector(); const reserveTrafficLightSpace = useTrafficLightSpace(); const onClose = useSettingsClose(); return ( @@ -111,13 +106,7 @@ function SettingsHeader() {
{ __( 'Settings' ) } - { hasAiSettings( connector.capabilities ) && ( - { __( 'AI' ) } - ) } - { __( 'Usage' ) } - { __( 'Keyboard' ) } - { __( 'Skills' ) } - { __( 'MCP' ) } + { __( 'Agent' ) }
{ onClose ? ( @@ -146,13 +135,13 @@ function PreferenceRow( { children: ReactNode; } ) { return ( -
-
-

{ title }

- { description ?

{ description }

: null } +
+
+ { title } + { description ? { description } : null }
-
{ children }
-
+
{ children }
+
); } @@ -264,55 +253,65 @@ function PreferencesPanel( { onChange: ( update: Partial< PreferencesFormData > ) => void; } ) { return ( -
-
-

{ __( 'General' ) }

- { saveError ? ( -
- { __( 'An error occurred while saving settings. Please try again.' ) } -
- ) : null } - - - onChange( { locale } ) } - /> - - - - 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 } ) } - /> - - - - - label={ __( 'When quitting with running sites' ) } - className={ styles.selectControlWide } - value={ data.quitSitesBehavior } - options={ QUIT_SITES_BEHAVIOR_ELEMENTS } - onChange={ ( quitSitesBehavior ) => onChange( { quitSitesBehavior } ) } - /> - -
+
- +
+
+
+
+

{ __( 'General' ) }

+
+
+ { saveError ? ( +
+ { __( 'An error occurred while saving settings. Please try again.' ) } +
+ ) : null } +
+ + + onChange( { locale } ) } + /> + + + + 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 } ) } + /> + + + + + label={ __( 'When quitting with running sites' ) } + className={ styles.selectControlWide } + value={ data.quitSitesBehavior } + options={ QUIT_SITES_BEHAVIOR_ELEMENTS } + onChange={ ( quitSitesBehavior ) => onChange( { quitSitesBehavior } ) } + /> + +
+
+ + + +
); } @@ -390,49 +389,38 @@ export function SettingsView( { }; return ( -
- { - if ( tabId && isSettingsTab( tabId ) ) { - onTabChange( tabId ); - } - } } - > - + +
+ { + if ( tabId && isSettingsTab( tabId ) ) { + onTabChange( tabId ); + } + } } + > + -
-
- - void handleSelectDefaultDirectory() } - onChange={ handleChange } - /> - - { hasAiSettings( connector.capabilities ) && ( +
+
+ + void handleSelectDefaultDirectory() } + onChange={ handleChange } + /> + - ) } - - - - - - - - - - - - +
-
- -
+
+
+ { SHOW_PREVIEW_CONTROLS ? : null } +
); } diff --git a/apps/ui/src/components/settings-view/keyboard-panel.tsx b/apps/ui/src/components/settings-view/keyboard-panel.tsx index 5a6946d631..ba784d4bb9 100644 --- a/apps/ui/src/components/settings-view/keyboard-panel.tsx +++ b/apps/ui/src/components/settings-view/keyboard-panel.tsx @@ -33,7 +33,7 @@ function getShortcutSections( isApple: boolean ): ShortcutSection[] { const navModifierKey = isApple ? '⌘' : 'Alt'; return [ { - title: __( 'General' ), + title: __( 'Global' ), shortcuts: [ { label: __( 'Open settings' ), keys: [ modifierKey, ',' ] } ], }, { @@ -74,20 +74,27 @@ function ShortcutKeys( { keys }: { keys: string[] } ) { export function KeyboardPanel() { return ( -
+
+
+
+

{ __( 'Keyboard' ) }

+
+
{ getShortcutSections( isAppleOS() ).map( ( section ) => ( -
-

{ section.title }

-
    +
    +

    { section.title }

    +
      { section.shortcuts.map( ( shortcut ) => ( -
    • - { shortcut.label } +
    • +
      + { shortcut.label } +
    • ) ) }
    -
+
) ) } -
+
); } diff --git a/apps/ui/src/components/settings-view/mcp-panel.test.tsx b/apps/ui/src/components/settings-view/mcp-panel.test.tsx index f8549b4bc4..d59b966277 100644 --- a/apps/ui/src/components/settings-view/mcp-panel.test.tsx +++ b/apps/ui/src/components/settings-view/mcp-panel.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; -import { McpPanel } from './mcp-panel'; +import { McpSection } from './mcp-panel'; import type { ReactNode } from 'react'; vi.mock( '@/components/learn-more', () => ( { @@ -20,7 +20,7 @@ function Providers( { children }: { children: ReactNode } ) { return { children }; } -describe( 'McpPanel', () => { +describe( 'McpSection', () => { const copyText = vi.fn(); beforeEach( () => { @@ -30,7 +30,7 @@ describe( 'McpPanel', () => { } ); it( 'copies the MCP configuration and shows copied feedback once the copy resolves', async () => { - render( , { wrapper: Providers } ); + render( , { wrapper: Providers } ); expect( screen.getByRole( 'heading', { name: 'MCP' } ) ).toBeInTheDocument(); expect( screen.getByText( /MCP lets other AI tools talk to Studio/ ) ).toBeInTheDocument(); @@ -48,7 +48,7 @@ describe( 'McpPanel', () => { copyText.mockRejectedValueOnce( error ); try { - render( , { wrapper: Providers } ); + render( , { wrapper: Providers } ); fireEvent.click( screen.getByRole( 'button', { name: 'Copy MCP configuration' } ) ); diff --git a/apps/ui/src/components/settings-view/mcp-panel.tsx b/apps/ui/src/components/settings-view/mcp-panel.tsx index aaebedca54..8694eecef2 100644 --- a/apps/ui/src/components/settings-view/mcp-panel.tsx +++ b/apps/ui/src/components/settings-view/mcp-panel.tsx @@ -4,28 +4,30 @@ import { CopyButton } from '@/components/copy-button'; import { LearnMoreLink } from '@/components/learn-more'; import styles from './style.module.css'; -export function McpPanel() { +export function McpSection() { 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.' - ) }{ ' ' } - -

-
-
{ configJson }
- +
+
+
+

{ __( '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.' + ) }{ ' ' } + +

-
-
+
+
+
{ configJson }
+ +
+ ); } diff --git a/apps/ui/src/components/settings-view/settings-preview.tsx b/apps/ui/src/components/settings-view/settings-preview.tsx new file mode 100644 index 0000000000..d7cd9d22ba --- /dev/null +++ b/apps/ui/src/components/settings-view/settings-preview.tsx @@ -0,0 +1,278 @@ +/** + * TEMP — design-only scenario control. + * + * A floating panel (dev builds only) that forces the settings UI signed-in or + * signed-out, so we can eyeball how the account sidebar and agent tab adapt + * without actually logging out. + * + * To remove: delete this file, drop `` + + * `SettingsPreviewProvider` from index.tsx, and swap the `usePreview*` hooks + * back to `useAuthUser` / `useAgenticFeatures` in account-section.tsx and + * usage-panel.tsx. + */ +import { createContext, useContext, useMemo, useState } from 'react'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; +import { useAuthUser } from '@/data/queries/use-auth-user'; +import type { AuthUser } from '@/data/core'; +import type { AgenticFeatures } from '@/data/queries/use-agentic-features'; +import type { CSSProperties, ReactNode } from 'react'; + +export const SHOW_PREVIEW_CONTROLS = import.meta.env.DEV && import.meta.env.MODE !== 'test'; + +type AuthScenario = 'default' | 'in' | 'out'; + +interface PreviewOverrides { + auth: AuthScenario; +} + +const DEFAULT_OVERRIDES: PreviewOverrides = { + auth: 'default', +}; + +const PREVIEW_USER: AuthUser = { + id: 424242, + displayName: 'Preview User', + email: 'preview@example.com', +}; + +interface PreviewContextValue { + overrides: PreviewOverrides; + setOverride: ( key: keyof PreviewOverrides, value: string ) => void; + reset: () => void; + active: boolean; +} + +const PreviewContext = createContext< PreviewContextValue >( { + overrides: DEFAULT_OVERRIDES, + setOverride: () => {}, + reset: () => {}, + active: false, +} ); + +export function SettingsPreviewProvider( { children }: { children: ReactNode } ) { + const [ overrides, setOverrides ] = useState< PreviewOverrides >( DEFAULT_OVERRIDES ); + + const value = useMemo< PreviewContextValue >( + () => ( { + overrides, + setOverride: ( key, next ) => + setOverrides( ( prev ) => ( { ...prev, [ key ]: next } ) as PreviewOverrides ), + reset: () => setOverrides( DEFAULT_OVERRIDES ), + active: Object.values( overrides ).some( ( scenario ) => scenario !== 'default' ), + } ), + [ overrides ] + ); + + return { children }; +} + +function usePreviewOverrides() { + return useContext( PreviewContext ); +} + +// --- Override-aware wrappers around the real data hooks --------------------- + +export function usePreviewAuthUser(): { data: AuthUser | null | undefined; isLoading: boolean } { + const { overrides } = usePreviewOverrides(); + const { data, isLoading } = useAuthUser(); + + if ( overrides.auth === 'in' ) { + return { data: data ?? PREVIEW_USER, isLoading: false }; + } + if ( overrides.auth === 'out' ) { + return { data: null, isLoading: false }; + } + return { data, isLoading }; +} + +export function usePreviewAgenticFeatures(): AgenticFeatures & { isReady: boolean } { + const { overrides } = usePreviewOverrides(); + const real = useAgenticFeatures(); + + if ( overrides.auth === 'out' ) { + return { enabled: false, reason: 'signed-out', isReady: true }; + } + // Forcing signed-in only clears a signed-out reason — offline still wins. + if ( overrides.auth === 'in' && real.reason === 'signed-out' ) { + return { enabled: true, reason: null, isReady: true }; + } + return real; +} + +// --- Floating control panel ------------------------------------------------- + +const SCENARIO_GROUPS: { + key: keyof PreviewOverrides; + label: string; + options: { value: string; label: string }[]; +}[] = [ + { + key: 'auth', + label: 'Account', + options: [ + { value: 'default', label: 'Default' }, + { value: 'in', label: 'Signed in' }, + { value: 'out', label: 'Signed out' }, + ], + }, +]; + +const panelStyle: CSSProperties = { + position: 'fixed', + insetBlockEnd: '16px', + insetInlineEnd: '16px', + zIndex: 9999, + inlineSize: '244px', + display: 'flex', + flexDirection: 'column', + gap: '12px', + padding: '14px', + borderRadius: '10px', + border: '1px solid var(--wpds-color-stroke-surface-neutral)', + background: 'var(--wpds-color-bg-surface-neutral)', + color: 'var(--wpds-color-fg-content-neutral)', + boxShadow: '0 8px 28px rgba(0, 0, 0, 0.24)', + fontFamily: 'var(--wpds-typography-font-family-body)', + fontSize: 'var(--wpds-typography-font-size-sm)', +}; + +const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', +}; + +const titleStyle: CSSProperties = { + fontWeight: 600, + fontSize: 'var(--wpds-typography-font-size-md)', +}; + +const hintStyle: CSSProperties = { + margin: 0, + color: 'var(--wpds-color-fg-content-neutral-weak)', + fontSize: 'var(--wpds-typography-font-size-xs)', + lineHeight: 'var(--wpds-typography-line-height-sm)', +}; + +const rowStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '5px', +}; + +const rowLabelStyle: CSSProperties = { + color: 'var(--wpds-color-fg-content-neutral-weak)', + fontSize: 'var(--wpds-typography-font-size-xs)', + textTransform: 'uppercase', + letterSpacing: '0.04em', +}; + +const segStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '3px', + padding: '3px', + borderRadius: 'var(--wpds-border-radius-sm)', + background: 'var(--wpds-color-bg-interactive-neutral-weak)', +}; + +function segButtonStyle( selected: boolean ): CSSProperties { + return { + appearance: 'none', + border: 0, + borderRadius: 'var(--wpds-border-radius-sm)', + padding: '4px 6px', + fontSize: 'var(--wpds-typography-font-size-xs)', + fontWeight: selected ? 600 : 400, + lineHeight: 1.4, + cursor: 'pointer', + background: selected ? 'var(--wpds-color-fg-content-neutral)' : 'transparent', + color: selected + ? 'var(--wpds-color-bg-surface-neutral)' + : 'var(--wpds-color-fg-content-neutral-weak)', + }; +} + +const iconButtonStyle: CSSProperties = { + appearance: 'none', + border: 0, + background: 'transparent', + color: 'var(--wpds-color-fg-content-neutral-weak)', + cursor: 'pointer', + fontSize: 'var(--wpds-typography-font-size-md)', + lineHeight: 1, + padding: '2px 4px', +}; + +const collapsedButtonStyle: CSSProperties = { + ...panelStyle, + inlineSize: 'auto', + flexDirection: 'row', + alignItems: 'center', + gap: '6px', + padding: '8px 12px', + cursor: 'pointer', + fontWeight: 600, +}; + +export function SettingsPreviewPanel() { + const { overrides, setOverride, reset, active } = usePreviewOverrides(); + const [ open, setOpen ] = useState( true ); + + if ( ! open ) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/ui/src/components/settings-view/skills-panel.tsx b/apps/ui/src/components/settings-view/skills-panel.tsx index eb069d25aa..0da34ea6b7 100644 --- a/apps/ui/src/components/settings-view/skills-panel.tsx +++ b/apps/ui/src/components/settings-view/skills-panel.tsx @@ -28,17 +28,19 @@ function SkillRow( { onToggle: () => void; } ) { return ( -
  • -
    - { skill.displayName } - { skill.description } +
  • +
    + { skill.displayName } + { skill.description } +
    +
    +
    -
  • ); } @@ -68,54 +70,54 @@ export function SkillsPanel() { }; return ( -
    -
    -
    -
    -

    { __( 'Skills' ) }

    - { availableSkills.length > 0 ? ( - - ) : null } -
    -

    +

    +
    +
    +

    { __( '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 && skillList.length === 0 ? ( -
    { __( 'No skills are available.' ) }
    - ) : null } - { skillList.length > 0 ? ( -
      - { skillList.map( ( skill ) => ( - handleToggle( skill ) } - /> - ) ) } -
    + { availableSkills.length > 0 ? ( +
    + +
    ) : null } -
    -
    +
    + { visibleError ?
    { visibleError }
    : null } + { isLoading ?
    { __( 'Loading skills…' ) }
    : null } + { ! isLoading && skillList.length === 0 ? ( +
    { __( 'No skills are available.' ) }
    + ) : null } + { skillList.length > 0 ? ( +
      + { skillList.map( ( skill ) => ( + handleToggle( skill ) } + /> + ) ) } +
    + ) : null } + ); } diff --git a/apps/ui/src/components/settings-view/studio-cli-section.tsx b/apps/ui/src/components/settings-view/studio-cli-section.tsx index 31cdefaad7..13529d2d45 100644 --- a/apps/ui/src/components/settings-view/studio-cli-section.tsx +++ b/apps/ui/src/components/settings-view/studio-cli-section.tsx @@ -47,42 +47,44 @@ export function StudioCliSection() { ); return ( -
    -
    -

    - { __( 'Studio CLI' ) } -

    - { externallyManaged ? ( - // The default open delay reads as unresponsive on a disabled - // control, so this tooltip gets its own faster provider. - - - { toggle } } - /> - } - > - { __( - 'This studio command was installed with the standalone CLI installer, so Studio can’t manage it. Run studio uninstall in a terminal to remove it.' - ) } - - - - ) : ( - toggle - ) } +
    +
    +
    +

    { __( 'Studio CLI' ) }

    +

    + { __( 'Use the studio command in any terminal to manage sites and run WP-CLI.' ) }{ ' ' } + +

    +
    +
    + { externallyManaged ? ( + // The default open delay reads as unresponsive on a disabled + // control, so this tooltip gets its own faster provider. + + + { toggle } } + /> + } + > + { __( + 'This studio command was installed with the standalone CLI installer, so Studio can’t manage it. Run studio uninstall in a terminal to remove it.' + ) } + + + + ) : ( + toggle + ) } +
    { savePreferences.isError ? (
    { __( 'An error occurred while updating the Studio CLI. Please try again.' ) }
    ) : null } -

    - { __( 'Use the studio command in any terminal to manage sites and run WP-CLI.' ) }{ ' ' } - -

    ); } diff --git a/apps/ui/src/components/settings-view/studio-code-panel.test.tsx b/apps/ui/src/components/settings-view/studio-code-panel.test.tsx index 4a519b47d4..6fabe052c2 100644 --- a/apps/ui/src/components/settings-view/studio-code-panel.test.tsx +++ b/apps/ui/src/components/settings-view/studio-code-panel.test.tsx @@ -7,18 +7,19 @@ import { } from '@/data/queries/use-agent-instructions'; import { StudioCodePanel } from './studio-code-panel'; -vi.mock( '@wordpress/dataviews', () => ( { - DataForm: ( { - data, - onChange, - }: { - data: { content: string }; - onChange: ( update: { content: string } ) => void; +vi.mock( '@wordpress/components', () => ( { + FormToggle: ( props: { + checked: boolean; + disabled?: boolean; + 'aria-label'?: string; + onChange: () => void; } ) => ( -