Skip to content
4 changes: 4 additions & 0 deletions apps/ui/src/components/settings-view/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );

vi.mock( './skills-panel', () => ( {
SkillsPanel: () => null,
} ) );

vi.mock( '@/data/queries/use-auth-user', () => ( {
useAuthUser: () => ( { data: null, isLoading: false } ),
useLogin: () => ( { mutate: vi.fn(), isPending: false } ),
Expand Down
9 changes: 7 additions & 2 deletions apps/ui/src/components/settings-view/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { AccountSection } from './account-section';
import { McpPanel } from './mcp-panel';
import { UNSET, toPreferencesFormData, toPreferencesPatch } from './preferences';
import { SkillsPanel } from './skills-panel';
import styles from './style.module.css';
import type { PreferencesFormData } from './preferences';
import type {
Expand All @@ -28,10 +29,10 @@ import type {
} from '@/data/core';
import type { ReactNode } from 'react';

type TabId = 'preferences' | 'mcp';
type TabId = 'preferences' | 'skills' | 'mcp';

export function isSettingsTab( value: string ): value is TabId {
return value === 'preferences' || value === 'mcp';
return value === 'preferences' || value === 'skills' || value === 'mcp';
}

export type SettingsTabId = TabId;
Expand Down Expand Up @@ -99,6 +100,7 @@ function SettingsHeader() {
<div className={ styles.headerTabs }>
<Tabs.List className={ styles.headerTabList }>
<Tabs.Tab tabId="preferences">{ __( 'Settings' ) }</Tabs.Tab>
<Tabs.Tab tabId="skills">{ __( 'Skills' ) }</Tabs.Tab>
<Tabs.Tab tabId="mcp">{ __( 'MCP' ) }</Tabs.Tab>
</Tabs.List>
</div>
Expand Down Expand Up @@ -404,6 +406,9 @@ export function SettingsView( {
onChange={ handleChange }
/>
</Tabs.Panel>
<Tabs.Panel tabId="skills">
<SkillsPanel />
</Tabs.Panel>
<Tabs.Panel tabId="mcp">
<McpPanel />
</Tabs.Panel>
Expand Down
227 changes: 227 additions & 0 deletions apps/ui/src/components/settings-view/skills-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
useInstallAllWordPressSkills,
useInstallWordPressSkill,
useRemoveWordPressSkill,
useWordPressSkills,
} from '@/data/queries/use-wordpress-skills';
import { SkillsPanel } from './skills-panel';
import type { ButtonHTMLAttributes, ReactNode } from 'react';

vi.mock( '@wordpress/components', () => ( {
FormToggle: ( props: {
checked: boolean;
disabled?: boolean;
'aria-label'?: string;
onChange: () => void;
} ) => (
<input
type="checkbox"
aria-label={ props[ 'aria-label' ] }
checked={ props.checked }
disabled={ props.disabled }
onChange={ props.onChange }
/>
),
} ) );

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 <button { ...props }>{ loading ? loadingAnnouncement : children }</button>;
},
} ) );

vi.mock( '@/components/learn-more', () => ( {
LearnMoreLink: () => <button type="button">Learn more</button>,
} ) );

vi.mock( '@/data/queries/use-wordpress-skills', () => ( {
useInstallAllWordPressSkills: vi.fn(),
useInstallWordPressSkill: vi.fn(),
useRemoveWordPressSkill: vi.fn(),
useWordPressSkills: vi.fn(),
} ) );

const useInstallAllWordPressSkillsMock = vi.mocked( useInstallAllWordPressSkills );
const useInstallWordPressSkillMock = vi.mocked( useInstallWordPressSkill );
const useRemoveWordPressSkillMock = vi.mocked( useRemoveWordPressSkill );
const useWordPressSkillsMock = vi.mocked( useWordPressSkills );

describe( 'SkillsPanel', () => {
const installSkillMutate = vi.fn();
const installAllSkillsMutate = vi.fn();
const removeSkillMutate = vi.fn();

beforeEach( () => {
vi.clearAllMocks();

useWordPressSkillsMock.mockReturnValue( {
data: [
{
id: 'studio-cli',
displayName: 'Studio CLI',
description: 'Manage sites from the command line.',
installed: true,
},
{
id: 'wp-rest-api',
displayName: 'WP REST API',
description: 'Query WordPress content over REST.',
installed: false,
},
],
isLoading: false,
error: null,
} as never );
useInstallWordPressSkillMock.mockReturnValue( {
mutate: installSkillMutate,
isPending: false,
error: null,
} as never );
useInstallAllWordPressSkillsMock.mockReturnValue( {
mutate: installAllSkillsMutate,
isPending: false,
error: null,
} as never );
useRemoveWordPressSkillMock.mockReturnValue( {
mutate: removeSkillMutate,
isPending: false,
error: null,
} as never );
} );

it( 'renders the intro copy and a toggle per skill reflecting installed state', () => {
render( <SkillsPanel /> );

expect( screen.getByRole( 'heading', { name: 'Skills' } ) ).toBeInTheDocument();
expect(
screen.getByText(
/Skills are reusable instructions that teach agents how to complete specialized WordPress tasks/
)
).toBeInTheDocument();
expect( screen.getByRole( 'checkbox', { name: 'Studio CLI' } ) ).toBeChecked();
expect( screen.getByRole( 'checkbox', { name: 'WP REST API' } ) ).not.toBeChecked();
} );

it( 'installs on toggling an uninstalled skill and removes on toggling an installed one', () => {
render( <SkillsPanel /> );

fireEvent.click( screen.getByRole( 'checkbox', { name: 'WP REST API' } ) );
fireEvent.click( screen.getByRole( 'checkbox', { name: 'Studio CLI' } ) );

expect( installSkillMutate ).toHaveBeenCalledWith( 'wp-rest-api' );
expect( removeSkillMutate ).toHaveBeenCalledWith( 'studio-cli' );
} );

it( 'installs all uninstalled skills through the header button', () => {
render( <SkillsPanel /> );

fireEvent.click( screen.getByRole( 'button', { name: 'Install all' } ) );

expect( installAllSkillsMutate ).toHaveBeenCalledWith( [ 'wp-rest-api' ] );
} );

it( 'hides the install-all button when every skill is installed', () => {
useWordPressSkillsMock.mockReturnValue( {
data: [
{
id: 'studio-cli',
displayName: 'Studio CLI',
description: 'Manage sites from the command line.',
installed: true,
},
],
isLoading: false,
error: null,
} as never );

render( <SkillsPanel /> );

expect( screen.queryByRole( 'button', { name: 'Install all' } ) ).not.toBeInTheDocument();
} );

it( 'disables the toggles and shows progress while installing all', () => {
useInstallAllWordPressSkillsMock.mockReturnValue( {
mutate: installAllSkillsMutate,
isPending: true,
error: null,
} as never );

render( <SkillsPanel /> );

expect( screen.getByRole( 'checkbox', { name: 'Studio CLI' } ) ).toBeDisabled();
expect( screen.getByRole( 'checkbox', { name: 'WP REST API' } ) ).toBeDisabled();
expect( screen.getByRole( 'button', { name: 'Installing all skills' } ) ).toBeDisabled();
} );

it( 'keeps the toggles and install-all button enabled during a single-skill mutation', () => {
useRemoveWordPressSkillMock.mockReturnValue( {
mutate: removeSkillMutate,
isPending: true,
variables: 'studio-cli',
error: null,
} as never );

render( <SkillsPanel /> );

expect( screen.getByRole( 'checkbox', { name: 'Studio CLI' } ) ).toBeEnabled();
expect( screen.getByRole( 'checkbox', { name: 'WP REST API' } ) ).toBeEnabled();
expect( screen.getByRole( 'button', { name: 'Install all' } ) ).toBeEnabled();
} );

it( 'shows the loading state', () => {
useWordPressSkillsMock.mockReturnValue( {
data: undefined,
isLoading: true,
error: null,
} as never );

render( <SkillsPanel /> );

expect( screen.getByText( 'Loading skills…' ) ).toBeInTheDocument();
} );

it( 'shows the empty state when no skills exist', () => {
useWordPressSkillsMock.mockReturnValue( {
data: [],
isLoading: false,
error: null,
} as never );

render( <SkillsPanel /> );

expect( screen.getByText( 'No skills are available.' ) ).toBeInTheDocument();
} );

it( 'surfaces query errors inline', () => {
useWordPressSkillsMock.mockReturnValue( {
data: undefined,
isLoading: false,
error: new Error( 'Failed to load skills' ),
} as never );

render( <SkillsPanel /> );

expect( screen.getByText( 'Failed to load skills' ) ).toBeInTheDocument();
} );
} );
121 changes: 121 additions & 0 deletions apps/ui/src/components/settings-view/skills-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { FormToggle } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/ui';
import { useMemo } from 'react';
import { LearnMoreLink } from '@/components/learn-more';
import {
useInstallAllWordPressSkills,
useInstallWordPressSkill,
useRemoveWordPressSkill,
useWordPressSkills,
} from '@/data/queries/use-wordpress-skills';
import styles from './style.module.css';
import type { SkillStatus } from '@/data/core';

function getErrorMessage( error: unknown ): string | null {
return error instanceof Error ? error.message : error ? String( error ) : null;
}

function SkillRow( {
skill,
checked,
disabled,
onToggle,
}: {
skill: SkillStatus;
checked: boolean;
disabled: boolean;
onToggle: () => void;
} ) {
return (
<li className={ styles.skillRow }>
<div className={ styles.skillDetails }>
<span className={ styles.skillName }>{ skill.displayName }</span>
<span className={ styles.skillDescription }>{ skill.description }</span>
</div>
<FormToggle
checked={ checked }
disabled={ disabled }
aria-label={ skill.displayName }
onChange={ onToggle }
/>
</li>
);
}

export function SkillsPanel() {
const { data: skills, isLoading, error } = useWordPressSkills();
const installSkill = useInstallWordPressSkill();
const installAllSkills = useInstallAllWordPressSkills();
const removeSkill = useRemoveWordPressSkill();
const skillList = skills ?? [];
const availableSkills = useMemo(
() => ( skills ?? [] ).filter( ( skill ) => ! skill.installed ),
[ skills ]
);
const visibleError =
getErrorMessage( error ) ??
getErrorMessage( installSkill.error ) ??
getErrorMessage( installAllSkills.error ) ??
getErrorMessage( removeSkill.error );

const handleToggle = ( skill: SkillStatus ) => {
if ( skill.installed ) {
removeSkill.mutate( skill.id );
} else {
installSkill.mutate( skill.id );
}
};

return (
<div className={ styles.skillsPanel }>
<section className={ styles.settingsPanelSection }>
<div className={ styles.settingsPanelHeader }>
<div className={ styles.skillsHeaderRow }>
<h2>{ __( 'Skills' ) }</h2>
{ availableSkills.length > 0 ? (
<Button
type="button"
variant="outline"
tone="neutral"
size="compact"
disabled={ installAllSkills.isPending }
loading={ installAllSkills.isPending }
loadingAnnouncement={ __( 'Installing all skills' ) }
onClick={ () =>
installAllSkills.mutate( availableSkills.map( ( skill ) => skill.id ) )
}
>
{ __( 'Install all' ) }
</Button>
) : null }
</div>
<p>
{ __(
'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.'
) }{ ' ' }
<LearnMoreLink docsLinksKey="docsSkills" />
</p>
</div>
{ visibleError ? <div className={ styles.errorMessage }>{ visibleError }</div> : null }
{ isLoading ? <div className={ styles.state }>{ __( 'Loading skills…' ) }</div> : null }
{ ! isLoading && skillList.length === 0 ? (
<div className={ styles.state }>{ __( 'No skills are available.' ) }</div>
) : null }
{ skillList.length > 0 ? (
<ul className={ styles.skillList }>
{ skillList.map( ( skill ) => (
<SkillRow
key={ skill.id }
skill={ skill }
checked={ skill.installed }
disabled={ installAllSkills.isPending }
onToggle={ () => handleToggle( skill ) }
/>
) ) }
</ul>
) : null }
</section>
</div>
);
}
Loading
Loading