Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions apps/ui/src/components/copy-button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ export function CopyButton( {
// `navigator.clipboard` is denied in the Electron desktop, which left the
// copy silently failing and the button stuck on "Copy".
const handleCopy = useCallback( () => {
void connector.copyText( text );
setCopied( true );
// Re-arm the reset on every click so copying again mid-"Copied" doesn't
// let the earlier timer flip the state back too soon.
if ( resetTimer.current ) {
clearTimeout( resetTimer.current );
}
resetTimer.current = setTimeout( () => setCopied( false ), 2000 );
void connector
.copyText( text )
.then( () => {
setCopied( true );
// Re-arm the reset on every click so copying again mid-"Copied"
// doesn't let the earlier timer flip the state back too soon.
if ( resetTimer.current ) {
clearTimeout( resetTimer.current );
}
resetTimer.current = setTimeout( () => setCopied( false ), 2000 );
} )
.catch( ( error ) => {
console.error( 'Failed to copy text:', error );
} );
}, [ connector, text ] );

const copiedLabel = __( 'Copied' );
Expand Down
4 changes: 3 additions & 1 deletion apps/ui/src/components/markdown/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Tooltip } from '@wordpress/ui';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Markdown } from '.';

const { copyText } = vi.hoisted( () => ( { copyText: vi.fn() } ) );
const { copyText } = vi.hoisted( () => ( {
copyText: vi.fn( () => Promise.resolve() ),
} ) );

vi.mock( '@/data/core', () => ( {
useConnector: () => ( {
Expand Down
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 @@ -70,6 +70,10 @@ vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );

vi.mock( './mcp-panel', () => ( {
McpPanel: () => <div data-testid="mcp-panel" />,
} ) );

vi.mock( '@/data/core/query-client', () => ( {
persister: { removeClient: vi.fn( () => Promise.resolve() ) },
} ) );
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 @@ -12,6 +12,7 @@ import { useInstalledApps } from '@/data/queries/use-installed-apps';
import { useSaveUserPreferences, useUserPreferences } from '@/data/queries/use-user-preferences';
import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed';
import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space';
import { McpPanel } from './mcp-panel';
import { UNSET, toPreferencesFormData, toPreferencesPatch } from './preferences';
import styles from './style.module.css';
import type { PreferencesFormData } from './preferences';
Expand All @@ -25,10 +26,10 @@ import type {
} from '@/data/core';
import type { ReactNode } from 'react';

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

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

export type SettingsTabId = TabId;
Expand Down Expand Up @@ -96,6 +97,7 @@ function SettingsHeader() {
<div className={ styles.headerTabs }>
<Tabs.List className={ styles.headerTabList }>
<Tabs.Tab tabId="preferences">{ __( 'Settings' ) }</Tabs.Tab>
<Tabs.Tab tabId="mcp">{ __( 'MCP' ) }</Tabs.Tab>
</Tabs.List>
</div>
</div>
Expand Down Expand Up @@ -362,6 +364,9 @@ export function SettingsView( {
onChange={ handleChange }
/>
</Tabs.Panel>
<Tabs.Panel tabId="mcp">
<McpPanel />
</Tabs.Panel>
</div>
</div>
</Tabs.Root>
Expand Down
64 changes: 64 additions & 0 deletions apps/ui/src/components/settings-view/mcp-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import '@testing-library/jest-dom/vitest';
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 type { ReactNode } from 'react';

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

vi.mock( '@/data/core', () => ( {
useConnector: vi.fn(),
} ) );

const useConnectorMock = vi.mocked( useConnector );

function Providers( { children }: { children: ReactNode } ) {
return <Tooltip.Provider>{ children }</Tooltip.Provider>;
}

describe( 'McpPanel', () => {
const copyText = vi.fn();

beforeEach( () => {
vi.clearAllMocks();
copyText.mockResolvedValue( undefined );
useConnectorMock.mockReturnValue( { copyText } as never );
} );

it( 'copies the MCP configuration and shows copied feedback once the copy resolves', async () => {
render( <McpPanel />, { wrapper: Providers } );

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.getByRole( 'status' ) ).toHaveTextContent( 'Copied' ) );
} );

it( 'does not show copied feedback when the copy fails', async () => {
const error = new Error( 'Clipboard unavailable' );
const consoleError = vi.spyOn( console, 'error' ).mockImplementation( () => undefined );
copyText.mockRejectedValueOnce( error );

try {
render( <McpPanel />, { wrapper: Providers } );

fireEvent.click( screen.getByRole( 'button', { name: 'Copy MCP configuration' } ) );

await waitFor( () =>
expect( consoleError ).toHaveBeenCalledWith( 'Failed to copy text:', error )
);
expect( screen.queryByText( 'Copied' ) ).not.toBeInTheDocument();
expect( screen.getByRole( 'status' ) ).toHaveTextContent( '' );
} finally {
consoleError.mockRestore();
}
} );
} );
31 changes: 31 additions & 0 deletions apps/ui/src/components/settings-view/mcp-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getMcpServerConfigJson } from '@studio/common/lib/mcp-config';
import { __ } from '@wordpress/i18n';
import { CopyButton } from '@/components/copy-button';
import { LearnMoreLink } from '@/components/learn-more';
import styles from './style.module.css';

export function McpPanel() {
const configJson = getMcpServerConfigJson();

return (
<div className={ styles.preferencesPanel }>
<section className={ styles.preferenceSectionGroup }>
<h2 className={ styles.preferenceSectionHeading }>{ __( 'MCP' ) }</h2>
<p className={ styles.sectionIntro }>
{ __(
'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.'
) }{ ' ' }
<LearnMoreLink docsLinksKey="docsMcp" />
</p>
<div className={ styles.codeBlockWrap }>
<pre className={ styles.codeBlock }>{ configJson }</pre>
<CopyButton
text={ configJson }
label={ __( 'Copy MCP configuration' ) }
className={ styles.codeBlockCopyButton }
/>
</div>
</section>
</div>
);
}
39 changes: 39 additions & 0 deletions apps/ui/src/components/settings-view/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,45 @@
line-height: var(--wpds-typography-line-height-sm);
}

.sectionIntro {
margin: 0 0 var(--wpds-dimension-padding-lg);
color: var(--wpds-color-fg-content-neutral-weak);
font-size: var(--wpds-typography-font-size-sm);
line-height: var(--wpds-typography-line-height-md);
}

.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;
}

.codeBlockCopyButton {
position: absolute;
inset-block-start: var(--wpds-dimension-padding-sm);
inset-inline-end: var(--wpds-dimension-padding-sm);
z-index: 1;
}

@media (max-width: 640px) {
.preferenceRow {
align-items: stretch;
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/lib/docs-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ type TranslatedLink = Partial< Record< SupportedLocale, string > > & { en: strin
* `apps/ui` actually links to are duplicated here.
*/
const DOCS_LINKS = {
docsMcp: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/mcp-on-studio/',
},
docsSites: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/sites/',
es: 'https://developer.wordpress.com/es/docs/herramientas-para-desarrolladores/studio/sitios/',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { StudioChatArtifactWidgetDraft } from '@studio/common/ai/chat-artif

const connectorMocks = vi.hoisted( () => ( {
readLocalMediaFile: vi.fn(),
copyText: vi.fn(),
copyText: vi.fn( () => Promise.resolve() ),
capabilities: { readLocalMedia: true },
} ) );

Expand Down