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..1824ae6298 100644 --- a/apps/ui/src/components/settings-view/account-section.tsx +++ b/apps/ui/src/components/settings-view/account-section.tsx @@ -1,15 +1,18 @@ 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 +21,113 @@ 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 ? ( + <> +
+

+ { __( 'Account' ) } +

+
+ +
+ { user.displayName } +

{ user.email }

+
+
+
+ +
+ +
-
- { user ? ( - - ) : ( + + ) : ( +
+

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

- ) } -
-
+ + ) } + + { user ? ( + + ) : null } + +
+

{ __( 'Help' ) }

+ +
+
); } 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 8fc3c26d48..2fe6894351 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 { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences'; import { AiPanel } from './ai-panel'; @@ -26,16 +27,37 @@ vi.mock( '@/data/core', () => ( { useConnector: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-agentic-features', () => ( { + useAgenticFeatures: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-user-preferences', () => ( { useUserPreferences: vi.fn(), useSaveUserPreferences: vi.fn(), } ) ); +vi.mock( './account-section', () => ( { + AccountSection: () =>
, +} ) ); + vi.mock( './studio-code-panel', () => ( { StudioCodePanel: () =>
, } ) ); +vi.mock( './skills-panel', () => ( { + SkillsPanel: () =>
, +} ) ); + +vi.mock( '@/components/agentic-signin-banner', () => ( { + SigninNotice: () =>
, +} ) ); + +vi.mock( '@/components/offline-banner', () => ( { + OfflineNotice: () =>
You're offline
, +} ) ); + const useConnectorMock = vi.mocked( useConnector ); +const useAgenticFeaturesMock = vi.mocked( useAgenticFeatures ); const useUserPreferencesMock = vi.mocked( useUserPreferences ); const useSaveUserPreferencesMock = vi.mocked( useSaveUserPreferences ); @@ -59,6 +81,7 @@ describe( 'AiPanel', () => { beforeEach( () => { vi.clearAllMocks(); + useAgenticFeaturesMock.mockReturnValue( { reason: null } as never ); useSaveUserPreferencesMock.mockReturnValue( { mutate } as never ); mockPreferences( true ); } ); @@ -89,11 +112,15 @@ describe( 'AiPanel', () => { expect( mutate ).toHaveBeenCalledWith( { agenticFeaturesEnabled: true } ); } ); - it( 'shows the toggle even when the host cannot switch back to the classic UI', () => { + it( 'locks the agentic features toggle and prompts sign-in when signed out', () => { mockConnector(); + useAgenticFeaturesMock.mockReturnValue( { reason: 'signed-out' } as never ); render( ); - expect( screen.getByRole( 'checkbox', { name: 'Agentic features' } ) ).toBeInTheDocument(); + const toggle = screen.getByRole( 'checkbox', { name: 'Agentic features' } ); + expect( toggle ).toBeDisabled(); + expect( toggle ).not.toBeChecked(); + expect( screen.getByText( 'You must log in for agentic features.' ) ).toBeInTheDocument(); } ); it( 'shows the global instructions editor alongside the agentic features toggle', () => { @@ -110,4 +137,30 @@ 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(); + useAgenticFeaturesMock.mockReturnValue( { reason: 'signed-out' } as never ); + render( ); + + expect( screen.queryByLabelText( 'Sign in to Studio' ) ).not.toBeInTheDocument(); + expect( screen.queryByRole( 'status' ) ).not.toBeInTheDocument(); + } ); + + it( 'shows the offline notice when offline', () => { + mockConnector(); + 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(); + 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 c63f9b65fb..774853a521 100644 --- a/apps/ui/src/components/settings-view/ai-panel.tsx +++ b/apps/ui/src/components/settings-view/ai-panel.tsx @@ -1,46 +1,70 @@ import { FormToggle } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { clsx } from 'clsx'; +import { OfflineNotice } from '@/components/offline-banner'; import { useConnector } from '@/data/core'; import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences'; +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'; +// Toggles whether chat is on offer. It's a stored preference, not a UI switch — +// turning it off keeps you in the agentic UI and preserves existing +// conversations. Switching all the way back to the classic UI lives in Settings. +// Agentic features need a WordPress.com account, so the toggle is locked (and +// reads off) until the user signs in. function AgenticFeaturesSection() { + const { reason } = usePreviewAgenticFeatures(); const { data: preferences, isLoading } = useUserPreferences(); const savePreferences = useSaveUserPreferences(); const enabled = preferences?.agenticFeaturesEnabled ?? true; + const signedOut = reason === 'signed-out'; return ( -
-
-
-

{ __( 'Agentic features' ) }

-

+

+
+
+

{ __( 'Agentic features' ) }

+

{ __( 'Chat with an agent that builds and edits your sites. Turning this off hides chat — your existing conversations are kept.' ) }

-
+
savePreferences.mutate( { agenticFeaturesEnabled: ! enabled } ) } />
-
+
+ { signedOut ? ( +

{ __( 'You must log in for agentic features.' ) }

+ ) : 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) 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 ff9b99b416..d029fcba9d 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,16 +113,6 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( { useUserPreferences: vi.fn(), } ) ); -vi.mock( '@/data/queries/use-wapuu-score', () => ( { - useWapuuScore: () => ( { data: null } ), -} ) ); - -// 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, } ) ); @@ -225,30 +211,13 @@ describe( 'SettingsView', () => { it( 'renders the AI tab with its panel', () => { render( ); - expect( screen.getByRole( 'button', { name: 'AI' } ) ).toBeInTheDocument(); - expect( screen.getByTestId( 'ai-panel' ) ).toBeInTheDocument(); - } ); - - it( 'offers the AI tab on every host, since chat can be toggled anywhere', () => { - useConnectorMock.mockReturnValue( { - selectDefaultSiteDirectory, - capabilities: { agentInstructions: false, switchToClassicUi: false }, - } as never ); - - render( ); - - expect( screen.getByRole( 'button', { name: 'AI' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Agent' } ) ).toBeInTheDocument(); expect( screen.getByTestId( 'ai-panel' ) ).toBeInTheDocument(); } ); - it( 'offers Switch to classic in the Settings tab when the host ships the classic UI', () => { - render( ); - - expect( screen.getByRole( 'heading', { name: 'Studio experience' } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: 'Switch to classic' } ) ).toBeInTheDocument(); - } ); - - it( 'hides Switch to classic when there is no classic UI to switch to', () => { + 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 }, @@ -256,20 +225,22 @@ describe( 'SettingsView', () => { render( ); - expect( screen.queryByRole( 'button', { name: 'Switch to classic' } ) ).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.getByRole( 'heading', { name: 'Preview' } ) ).toBeInTheDocument(); expect( screen.getByText( 'New chat' ) ).toBeInTheDocument(); expect( screen.getByText( 'Send message' ) ).toBeInTheDocument(); expect( screen.getByLabelText( 'Control + Comma' ) ).toBeInTheDocument(); diff --git a/apps/ui/src/components/settings-view/index.tsx b/apps/ui/src/components/settings-view/index.tsx index c3737d3947..5ed678719c 100644 --- a/apps/ui/src/components/settings-view/index.tsx +++ b/apps/ui/src/components/settings-view/index.tsx @@ -1,7 +1,6 @@ import { supportedLocaleNames } from '@studio/common/lib/locale'; import { SUPPORTED_EDITORS, supportedEditorConfig } from '@studio/common/lib/user-settings/editor'; import { SUPPORTED_TERMINALS, terminalConfig } from '@studio/common/lib/user-settings/terminal'; -import { CheckboxControl } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { close, file, Icon } from '@wordpress/icons'; import { Button, IconButton, SelectControl } from '@wordpress/ui'; @@ -17,13 +16,15 @@ 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 { WapuuScore } from './wapuu-score'; import type { PreferencesFormData } from './preferences'; import type { ColorScheme, @@ -35,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 ]; @@ -86,6 +87,13 @@ const QUIT_SITES_BEHAVIOR_ELEMENTS: { { value: 'stop', label: __( 'Stop sites' ) }, ]; +type AnalyticsChoice = 'share' | 'off'; + +const ANALYTICS_ELEMENTS: { value: AnalyticsChoice; label: string }[] = [ + { value: 'share', label: __( 'Share anonymous data' ) }, + { value: 'off', label: __( 'Don’t share' ) }, +]; + const LOCALE_ELEMENTS: { value: SupportedLocale; label: string }[] = Object.entries( supportedLocaleNames ).map( ( [ value, label ] ) => ( { value: value as SupportedLocale, label } ) ); @@ -105,11 +113,7 @@ function SettingsHeader() {
{ __( 'Settings' ) } - { __( 'AI' ) } - { __( 'Usage' ) } - { __( 'Keyboard' ) } - { __( 'Skills' ) } - { __( 'MCP' ) } + { __( 'Agent' ) }
{ onClose ? ( @@ -138,13 +142,13 @@ function PreferenceRow( { children: ReactNode; } ) { return ( -
-
-

{ title }

- { description ?

{ description }

: null } +
+
+ { title } + { description ? { description } : null }
-
{ children }
-
+
{ children }
+
); } @@ -240,26 +244,35 @@ function DefaultSiteDirectoryField( { value, onSelect }: { value: string; onSele ); } +// Leaving the agentic UI entirely is a separate, heavier action than the AI +// tab's chat toggle: it reloads the window into the classic Studio interface. +// Only hosts that ship the classic renderer can switch (see capabilities). function StudioExperienceSection() { const connector = useConnector(); if ( ! connector.capabilities.switchToClassicUi ) { return null; } return ( -
- - - +
+
+
+

{ __( 'Studio Beta' ) }

+

+ { __( 'You’re using the new Studio with AI chat and a built-in site preview.' ) } +

+
+
+ +
+
); } @@ -280,65 +293,75 @@ 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 } ) } - /> - - - onChange( { analyticsEnabled } ) } - /> - -
+
- - - +
+
+
+
+

{ __( '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 } ) } + /> + + + + label={ __( 'Help improve Studio by sharing anonymous usage data' ) } + className={ styles.selectControlAuto } + value={ data.analyticsEnabled ? 'share' : 'off' } + options={ ANALYTICS_ELEMENTS } + onChange={ ( choice ) => onChange( { analyticsEnabled: choice === 'share' } ) } + /> + +
+
+ + + + +
); } @@ -416,47 +439,38 @@ export function SettingsView( { }; return ( -
- { - if ( tabId && isSettingsTab( tabId ) ) { - onTabChange( tabId ); - } - } } - > - + +
+ { + if ( tabId && isSettingsTab( tabId ) ) { + onTabChange( tabId ); + } + } } + > + -
-
- - void handleSelectDefaultDirectory() } - onChange={ handleChange } - /> - - - - - - - - - - - - - - - - +
+
+ + 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..9ce465ba2a 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, ',' ] } ], }, { @@ -46,9 +46,9 @@ function getShortcutSections( isApple: boolean ): ShortcutSection[] { ], }, { - title: __( 'Site preview' ), + title: __( 'Preview' ), shortcuts: [ - { label: __( 'Toggle site preview' ), keys: [ modifierKey, 'Shift', 'B' ] }, + { label: __( 'Toggle preview' ), keys: [ modifierKey, 'Shift', 'B' ] }, { label: __( 'Reload preview' ), keys: [ modifierKey, 'R' ] }, { label: __( 'Go back in preview' ), keys: [ navModifierKey, '←' ] }, { label: __( 'Go forward in preview' ), keys: [ navModifierKey, '→' ] }, @@ -72,22 +72,39 @@ function ShortcutKeys( { keys }: { keys: string[] } ) { ); } -export function KeyboardPanel() { +function ShortcutGroup( { section }: { section: ShortcutSection } ) { return ( -
- { getShortcutSections( isAppleOS() ).map( ( section ) => ( -
-

{ section.title }

-
    - { section.shortcuts.map( ( shortcut ) => ( -
  • - { shortcut.label } - -
  • - ) ) } -
-
- ) ) } +
+

{ section.title }

+
    + { section.shortcuts.map( ( shortcut ) => ( +
  • +
    + { shortcut.label } +
    + +
  • + ) ) } +
); } + +export function KeyboardPanel() { + const [ globalSection, ...columnSections ] = getShortcutSections( isAppleOS() ); + return ( +
+
+
+

{ __( 'Keyboard' ) }

+
+
+ +
+ { columnSections.map( ( section ) => ( + + ) ) } +
+
+ ); +} 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..e4773f3d7e --- /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 { ...real, enabled: false, chatEnabled: 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 { ...real, 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..56e3902125 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 @@ -5,20 +5,22 @@ import { useAgentInstructions, useSaveAgentInstructions, } from '@/data/queries/use-agent-instructions'; +import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; 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; } ) => ( -