From 572a23a4304c5a2b0c46951914c20eacc5081bc7 Mon Sep 17 00:00:00 2001 From: Todd Wright Date: Tue, 1 Sep 2026 20:23:05 +1000 Subject: [PATCH 1/7] Jetpack AI: Hide the legacy panel once a site is eligible for the WordPress Agent --- .../update-wordpress-agent-notice-eligibility | 4 + .../ai-sidebar/class-jetpack-ai-sidebar.php | 69 +++++++- .../test/index.test.tsx | 26 +++ .../wordpress-agent-notice/index.tsx | 43 +++-- .../wordpress-agent-notice/open-agent.ts | 21 +++ .../test/index.test.tsx | 40 +++++ .../ai-sidebar/Jetpack_AI_Sidebar_Test.php | 156 ++++++++++++++++++ 7 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 projects/plugins/jetpack/changelog/update-wordpress-agent-notice-eligibility diff --git a/projects/plugins/jetpack/changelog/update-wordpress-agent-notice-eligibility b/projects/plugins/jetpack/changelog/update-wordpress-agent-notice-eligibility new file mode 100644 index 000000000000..6befd84a07a0 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-wordpress-agent-notice-eligibility @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Jetpack AI: Hide the legacy AI panel once a site is eligible to turn on the WordPress Agent, not only once it is switched on. diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php index 9bb1fb2d352d..f158917f4234 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php @@ -14,6 +14,7 @@ use Automattic\Jetpack\Agents_Manager\Agents_Manager; use Automattic\Jetpack\Connection\Manager as Connection_Manager; +use Automattic\Jetpack\Current_Plan; use Automattic\Jetpack\SEO\Ai_Seo; use Automattic\Jetpack\Status; use Automattic\Jetpack\Status\Host; @@ -454,8 +455,13 @@ private static function get_jetpack_ai_sidebar_preview_config(): array { $features['blockTransformations'] = (bool) $features['blockTransformations'] && $writing_on; return array( - 'enabled' => self::is_jetpack_ai_sidebar_preview_enabled(), - 'features' => $features, + 'enabled' => self::is_jetpack_ai_sidebar_preview_enabled(), + 'features' => $features, + // Kept out of $features: that array is public and host-filterable + // (jetpack_ai_sidebar_preview_features), and this is neither — it is + // read only by the WordPress Agent notice, to decide whether its own + // action button has a working agent to open. + 'agentNoticeActionAvailable' => self::is_agent_action_available(), ); } @@ -498,13 +504,49 @@ public static function register_toolbar_button_extension(): void { /** * Whether the legacy AI panel should point people at the WordPress Agent. * - * The notice replaces the panel, so it needs an agent to send people to. A - * disconnected user gets the Agents Manager's reduced build, which has no chat. + * True once the site can send people to a working agent (the existing gate), or, + * ahead of that, once the site is eligible to turn the Agent on — the old panel is + * on its way out either way. Eligible alone may have no agent to open yet, so the + * notice's action button follows the narrower is_agent_action_available() instead. * * @return bool */ public static function is_agent_notice_enabled(): bool { - return self::should_expose_provider() && ! self::is_agents_manager_disconnected(); + if ( self::is_agent_action_available() ) { + return true; + } + + return self::is_wordpress_agent_eligible() + && self::is_supported_provider_surface() + && self::has_ai_features(); + } + + /** + * Whether the site is eligible to turn the WordPress Agent on, whether or not + * it has done so yet. + * + * On WordPress.com Simple this file runs inside the wpcom process, where the + * Big Sky mu-plugin's own functions are always loaded and give the direct + * answer, including WordPress.com's separate free-trial eligibility — not + * otherwise visible to Jetpack. Atomic and self-hosted sites have no such + * functions, so they fall back to the plan check alone, which cannot see the + * free trial. + * + * @return bool + */ + private static function is_wordpress_agent_eligible(): bool { + $host = new Host(); + + if ( ! $host->is_wpcom_platform() || ! class_exists( 'Big_Sky' ) ) { + return false; + } + + if ( function_exists( 'big_sky_is_available_for_site' ) && function_exists( 'big_sky_is_enabled' ) ) { + // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by WPCOM's Big Sky mu-plugin; guarded by function_exists() above. + return big_sky_is_available_for_site() || big_sky_is_enabled(); + } + + return Current_Plan::supports( 'big-sky' ); } /** @@ -525,6 +567,23 @@ public static function register_agent_notice_extension(): void { \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_AGENT_NOTICE_EXTENSION ); } + /** + * Whether the notice's action button has a working agent to send people to. + * + * The notice itself shows earlier, once the site is merely eligible for the + * Agent — see is_agent_notice_enabled(). This is narrower: the original + * enabled gate, so the button stays hidden rather than open a chat that + * does not exist yet, or one under a different provider entirely. Reaches + * the notice through get_jetpack_ai_sidebar_preview_config(), not a + * registered extension — it is a single flag for one button, not a surface + * with its own availability and reason. + * + * @return bool + */ + public static function is_agent_action_available(): bool { + return self::should_expose_provider() && ! self::is_agents_manager_disconnected(); + } + /** * Add Jetpack AI Sidebar-specific data to externally emitted Agents Manager payloads. * diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx index c56d7d930d28..1130cb590b1e 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx @@ -277,6 +277,7 @@ describe( 'AiAssistantPluginSidebar', () => { afterEach( () => { delete ( window as unknown as { __agentsManagerActions?: unknown } ).__agentsManagerActions; + delete ( window as unknown as { agentsManagerData?: unknown } ).agentsManagerData; } ); describe( 'WordPress Agent notice', () => { @@ -284,6 +285,11 @@ describe( 'AiAssistantPluginSidebar', () => { jest .mocked( getFeatureAvailability ) .mockImplementation( feature => feature === AGENT_NOTICE_FEATURE ); + // The notice reads this to decide whether its action button has a + // working agent to open, the way production's server payload does. + ( window as unknown as { agentsManagerData?: unknown } ).agentsManagerData = { + jetpackAiSidebar: { agentNoticeActionAvailable: true }, + }; } ); it( 'shows the notice in the Jetpack sidebar, the document panel and the pre-publish panel', () => { @@ -346,6 +352,26 @@ describe( 'AiAssistantPluginSidebar', () => { ); } ); + it( 'still replaces the AI panel, without the action, on a site merely eligible for the Agent', () => { + ( window as unknown as { agentsManagerData?: unknown } ).agentsManagerData = { + jetpackAiSidebar: { agentNoticeActionAvailable: false }, + }; + + render( ); + + expect( + within( screen.getByTestId( 'document-panel' ) ).getByText( + 'AI tools have moved to the WordPress Agent.' + ) + ).toBeInTheDocument(); + expect( screen.queryByText( 'Get Feedback' ) ).not.toBeInTheDocument(); + expect( + within( screen.getByTestId( 'document-panel' ) ).queryByRole( 'button', { + name: 'WordPress Agent', + } ) + ).not.toBeInTheDocument(); + } ); + it( 'leaves the collapsed panels alone when there is no notice to show', () => { withFeatures( DEFAULT_FEATURES ); diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx index 4753b8f9fccc..a2319caf36a2 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx @@ -18,6 +18,7 @@ import { Notice } from '@wordpress/ui'; import { getFeatureAvailability } from '../../../../blocks/ai-assistant/lib/utils/get-feature-availability'; import bigSkyIcon from './big-sky-icon'; import { + isAgentActionAvailable, resumeWordPressAgentChat, setWordPressAgentChatOpen, useIsWordPressAgentChatVisible, @@ -107,6 +108,10 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic const { tracks } = useAnalytics(); const { set } = useDispatch( preferencesStore ); const eventProperties = useEventProperties( placement ); + // The setup supports opening a chat (server-side) and one has actually mounted + // (client-side) — both are needed, since eligible-but-not-yet-enabled sites hit + // this component too, and a chat mounted for a different provider is not this one. + const canOpenAgent = isAgentActionAvailable(); const isAgentReady = useIsWordPressAgentReady(); const isChatOnScreen = useIsWordPressAgentChatVisible(); @@ -136,27 +141,29 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic style={ { gridTemplateColumns: 'auto minmax(0, 1fr) auto' } } > - { createInterpolateElement( - // translators: is the WordPress Agent's icon. "Agent" is the label on an editor toolbar button. - __( - 'AI tools have moved to the WordPress Agent. Look for the button at the top of the screen.', - 'jetpack' - ), - { - label: , - icon: ( - - ), - } - ) } + { canOpenAgent + ? createInterpolateElement( + // translators: is the WordPress Agent's icon. "Agent" is the label on an editor toolbar button. + __( + 'AI tools have moved to the WordPress Agent. Look for the button at the top of the screen.', + 'jetpack' + ), + { + label: , + icon: ( + + ), + } + ) + : __( 'AI tools have moved to the WordPress Agent.', 'jetpack' ) } - { isAgentReady && ( + { canOpenAgent && isAgentReady && ( + ) } + + { ! canOpenAgent && ( + ) } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx index f3ec543783e9..7b4d4288b3c6 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx @@ -42,6 +42,7 @@ jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { useAnalytics: () => ( { tracks: { recordEvent: mockRecordEvent } } ), + getSiteFragment: () => 'example.wordpress.com', } ) ); jest.mock( '@automattic/jetpack-ai-client', () => ( { @@ -50,6 +51,8 @@ jest.mock( '@automattic/jetpack-ai-client', () => ( { jest.mock( '@automattic/jetpack-script-data', () => ( { getSiteType: () => mockSiteType, + getMyJetpackUrl: ( section: string ) => + `https://example.com/wp-admin/admin.php?page=my-jetpack${ section }`, } ) ); // The component reads the post type off the shared registry by store name, so @@ -122,17 +125,16 @@ describe( 'WordPressAgentNotice', () => { // aria-disabled rather than the disabled attribute, so the button stays // focusable and announces why. - expect( screen.getByRole( 'button', { name: /WordPress Agent/ } ) ).toHaveAttribute( - 'aria-disabled', - 'true' - ); + expect( + screen.getByRole( 'button', { name: 'WordPress Agent is already open' } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); } ); it( 'does nothing when the disabled action is clicked', async () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: /WordPress Agent/ } ) ); + await user.click( screen.getByRole( 'button', { name: 'WordPress Agent is already open' } ) ); expect( setWordPressAgentChatOpen ).not.toHaveBeenCalled(); expect( mockRecordEvent ).not.toHaveBeenCalled(); @@ -163,7 +165,13 @@ describe( 'WordPressAgentNotice', () => { it( 'offers no action it cannot carry out', () => { render( ); - expect( screen.queryByRole( 'button', { name: 'WordPress Agent' } ) ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: 'Open WordPress Agent' } ) + ).not.toBeInTheDocument(); + // The Agent is on, so there is nothing to enable either. + expect( + screen.queryByRole( 'link', { name: 'Enable WordPress Agent' } ) + ).not.toBeInTheDocument(); } ); it( 'can still be dismissed', async () => { @@ -190,11 +198,67 @@ describe( 'WordPressAgentNotice', () => { expect( screen.queryByText( /Look for the "Ask AI"/ ) ).not.toBeInTheDocument(); } ); - it( 'offers no action, even once the Agents Manager is ready', () => { + it( 'offers nothing to open, even once the Agents Manager is ready', () => { mockIsAgentReady = true; render( ); - expect( screen.queryByRole( 'button', { name: 'WordPress Agent' } ) ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'button', { name: 'Open WordPress Agent' } ) + ).not.toBeInTheDocument(); + } ); + + it.each( [ 'simple', 'woa' ] as const )( + 'sends a %s site to its AI tools settings on WordPress.com to enable the Agent', + siteType => { + mockSiteType = siteType; + render( ); + + const link = screen.getByRole( 'link', { name: 'Enable WordPress Agent' } ); + expect( link ).toHaveAttribute( + 'href', + 'https://wordpress.com/sites/example.wordpress.com/settings/ai-tools' + ); + // WordPress.com is another site, so it opens in a new tab like the docs link. + expect( link ).toHaveAttribute( 'target', '_blank' ); + expect( link ).toHaveAttribute( 'rel', expect.stringContaining( 'noopener' ) ); + } + ); + + it( 'sends a self-hosted site to My Jetpack to enable the Agent', () => { + mockSiteType = 'jetpack'; + render( ); + + const link = screen.getByRole( 'link', { name: 'Enable WordPress Agent' } ); + expect( link ).toHaveAttribute( + 'href', + 'https://example.com/wp-admin/admin.php?page=my-jetpack#/overview' + ); + // My Jetpack is part of the same admin, so it opens in place. + expect( link ).not.toHaveAttribute( 'target' ); + } ); + + it( 'shows the Enable action as a plain button, without the Agent icon', () => { + render( ); + + const link = screen.getByRole( 'link', { name: 'Enable WordPress Agent' } ); + // eslint-disable-next-line testing-library/no-node-access + expect( link.querySelector( 'svg' ) ).not.toBeInTheDocument(); + } ); + + it( 'records an enable click, apart from an open one', async () => { + const user = userEvent.setup(); + render( ); + + const link = screen.getByRole( 'link', { name: 'Enable WordPress Agent' } ); + // jsdom cannot follow the link, so stop it from trying. + link.addEventListener( 'click', event => event.preventDefault() ); + await user.click( link ); + + expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).toMatchObject( { + action: 'enable', + placement: 'jetpack-sidebar', + } ); + expect( setWordPressAgentChatOpen ).not.toHaveBeenCalled(); } ); it( 'keeps the documentation link', () => { @@ -255,16 +319,35 @@ describe( 'WordPressAgentNotice', () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( setWordPressAgentChatOpen ).toHaveBeenCalledWith( true ); } ); + it( 'shows the Open action as a plain button, without the Agent icon', () => { + render( ); + + const button = screen.getByRole( 'button', { name: 'Open WordPress Agent' } ); + // eslint-disable-next-line testing-library/no-node-access + expect( button.querySelector( 'svg' ) ).not.toBeInTheDocument(); + } ); + + it( 'records an open click, apart from an enable one', async () => { + const user = userEvent.setup(); + render( ); + + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); + + expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).toMatchObject( { + action: 'open', + } ); + } ); + it( 'opens the chat on its default screen, not wherever it was last left', async () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( resumeWordPressAgentChat ).toHaveBeenCalled(); // Resetting after the open would show the old screen first. @@ -277,7 +360,7 @@ describe( 'WordPressAgentNotice', () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).toMatchObject( { placement: 'jetpack-sidebar', @@ -290,7 +373,7 @@ describe( 'WordPressAgentNotice', () => { mockPostType = 'page'; render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); const expected = { @@ -310,7 +393,7 @@ describe( 'WordPressAgentNotice', () => { }; render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); const expected = { surface: 'block_editor', is_test: true, is_a11n: true }; @@ -322,7 +405,7 @@ describe( 'WordPressAgentNotice', () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); const properties = propertiesOf( 'jetpack_big_sky_agent_notice_click' ); expect( properties ).toMatchObject( { is_test: false } ); @@ -334,7 +417,7 @@ describe( 'WordPressAgentNotice', () => { mockSiteType = siteType; render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).toMatchObject( { site_type: siteType, @@ -346,7 +429,7 @@ describe( 'WordPressAgentNotice', () => { mockPostType = undefined; render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).not.toHaveProperty( 'post_type' @@ -364,7 +447,7 @@ describe( 'WordPressAgentNotice', () => { ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); const properties = propertiesOf( 'jetpack_big_sky_agent_notice_click' ); expect( properties ).not.toHaveProperty( 'surface' ); @@ -376,7 +459,7 @@ describe( 'WordPressAgentNotice', () => { mockCurrentTier = undefined; render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( propertiesOf( 'jetpack_big_sky_agent_notice_click' ) ).not.toHaveProperty( 'current_tier_slug' @@ -387,7 +470,7 @@ describe( 'WordPressAgentNotice', () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( isDismissed() ).toBeFalsy(); } ); diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/types.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/types.ts index f7a983d2092b..464943188821 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/types.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/types.ts @@ -25,8 +25,12 @@ export type EditorSelect = { getCurrentPostType?: () => string | undefined; }; +export type WordPressAgentNoticeAction = 'open' | 'enable'; + export type WordPressAgentNoticeEventProperties = TracksAudienceProperties & { placement: WordPressAgentNoticePlacement; + // Which action button a click event came from; absent on dismissals. + action?: WordPressAgentNoticeAction; // The family's settled editor value, present while the core/editor store is registered. surface?: 'block_editor'; site_type: SiteType; From 651448231f134104231834681ea7d7e074f9ca8f Mon Sep 17 00:00:00 2001 From: Todd Wright Date: Wed, 2 Sep 2026 14:07:15 +1000 Subject: [PATCH 4/7] Jetpack AI: Build the agent notice actions on @wordpress/ui The notice sits inside a @wordpress/ui Notice, so its buttons and icon now come from the same package rather than @wordpress/components. Both actions stop shrinking so a long label pushes the docs link down instead of breaking mid-phrase. The enable link opens in the same tab: the editor's own unsaved-changes prompt guards the draft, and a fresh load on the way back shows the enabled state. --- .../test/index.test.tsx | 4 ++ .../wordpress-agent-notice/index.tsx | 49 ++++++++----------- .../test/index.test.tsx | 7 ++- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx index 4755bc63ed59..ab7d2a71c17c 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx @@ -176,6 +176,7 @@ jest.mock( '@wordpress/components', () => ( { } ) ); jest.mock( '@wordpress/ui', () => ( { + Icon: () => , Button: Object.assign( ( { children, onClick }: { children: React.ReactNode; onClick?: () => void } ) => ( @@ -185,6 +186,9 @@ jest.mock( '@wordpress/ui', () => ( { Link: ( { children, href }: { children: React.ReactNode; href: string } ) => ( { children } ), + LinkButton: ( { children, href }: { children: React.ReactNode; href: string } ) => ( + { children } + ), Notice: { Root: ( { children }: { children: React.ReactNode } ) =>
{ children }
, Description: ( { children }: { children: React.ReactNode } ) => { children }, diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx index a747b0a7282a..962230a2c4d4 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx @@ -6,12 +6,11 @@ import { getRedirectUrl } from '@automattic/jetpack-components'; import { getMyJetpackUrl, getSiteType } from '@automattic/jetpack-script-data'; import { getSiteFragment, useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import { speak } from '@wordpress/a11y'; -import { Button, Icon } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as preferencesStore } from '@wordpress/preferences'; -import { Notice } from '@wordpress/ui'; +import { Button, Icon, LinkButton, Notice } from '@wordpress/ui'; /** * Internal dependencies */ @@ -46,27 +45,24 @@ const DOCS_URL = getRedirectUrl( 'jetpack-ai-docs-wordpress-agent' ); // it as a side effect. const EDITOR_STORE = 'core/editor'; +// Notice.Actions lets its buttons shrink, which breaks a label mid-phrase in a +// narrow sidebar. Not shrinking sends the docs link to the next line instead. +const ACTION_BUTTON_STYLE = { flexShrink: 0 } as const; + /** * Where a site turns the WordPress Agent on: the WordPress.com AI tools settings * for Simple and Atomic, matching the dashboard banner, and My Jetpack elsewhere. * - * @return {{href: string, isExternal: boolean}} The settings URL, and whether it leaves this admin. + * @return {string} The settings URL. */ -function getEnableAgentLink(): { href: string; isExternal: boolean } { +function getEnableAgentUrl(): string { if ( getSiteType() === 'jetpack' ) { - return { href: getMyJetpackUrl( '#/overview' ), isExternal: false }; + return getMyJetpackUrl( '#/overview' ); } - return { - href: `https://wordpress.com/sites/${ getSiteFragment() }/settings/ai-tools`, - isExternal: true, - }; + return `https://wordpress.com/sites/${ getSiteFragment() }/settings/ai-tools`; } -// Secondary buttons set `white-space: nowrap` and a fixed height, so a long -// translation cannot fit a narrow sidebar. Wrapping suits any label. -const ACTION_BUTTON_STYLE = { whiteSpace: 'normal', height: 'auto', minHeight: '36px' } as const; - function useEventProperties( placement: WordPressAgentNoticePlacement ): WordPressAgentNoticeEventProperties { @@ -135,7 +131,6 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic const canOpenAgent = isAgentActionAvailable(); const isAgentReady = useIsWordPressAgentReady(); const isChatOnScreen = useIsWordPressAgentChatVisible(); - const enableLink = getEnableAgentLink(); const openAgent = () => { tracks.recordEvent( 'jetpack_big_sky_agent_notice_click', { @@ -197,15 +192,13 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic { canOpenAgent && isAgentReady && ( + ) } diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx index 7b4d4288b3c6..faf336be635f 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx @@ -218,9 +218,9 @@ describe( 'WordPressAgentNotice', () => { 'href', 'https://wordpress.com/sites/example.wordpress.com/settings/ai-tools' ); - // WordPress.com is another site, so it opens in a new tab like the docs link. - expect( link ).toHaveAttribute( 'target', '_blank' ); - expect( link ).toHaveAttribute( 'rel', expect.stringContaining( 'noopener' ) ); + // In place, so the editor's own unsaved-changes prompt guards the draft + // and the notice is fresh on the way back. + expect( link ).not.toHaveAttribute( 'target' ); } ); @@ -233,7 +233,6 @@ describe( 'WordPressAgentNotice', () => { 'href', 'https://example.com/wp-admin/admin.php?page=my-jetpack#/overview' ); - // My Jetpack is part of the same admin, so it opens in place. expect( link ).not.toHaveAttribute( 'target' ); } ); From 7cef1c597f3173373f76392a245bccc3dfc42bf1 Mon Sep 17 00:00:00 2001 From: Todd Wright Date: Wed, 2 Sep 2026 15:46:45 +1000 Subject: [PATCH 5/7] Jetpack AI: Trim the agent notice comments Several docblocks had grown into essays that restated what the code shows. Keep the gotchas a reader cannot see from the code and drop the rest. --- .../ai-sidebar/class-jetpack-ai-sidebar.php | 35 +++++-------------- .../test/index.test.tsx | 2 -- .../wordpress-agent-notice/index.tsx | 17 ++++----- .../wordpress-agent-notice/open-agent.ts | 11 +++--- .../test/index.test.tsx | 3 -- .../ai-sidebar/Jetpack_AI_Sidebar_Test.php | 24 +++++-------- 6 files changed, 27 insertions(+), 65 deletions(-) diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php index ebb1c27e6316..ee9748e329cf 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php @@ -46,8 +46,7 @@ class Jetpack_AI_Sidebar { * @return void */ public static function init(): void { - // The notice shows on sites merely eligible for the Agent, where the - // sidebar gate below is still closed, so it hooks ahead of that gate. + // Ahead of the sidebar gate: the notice also shows on sites merely eligible for the Agent. add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_agent_notice_extension' ), 99 ); // Gate the whole sidebar entrypoint on the preview surface, which is @@ -458,10 +457,7 @@ private static function get_jetpack_ai_sidebar_preview_config(): array { return array( 'enabled' => self::is_jetpack_ai_sidebar_preview_enabled(), 'features' => $features, - // Kept out of $features: that array is public and host-filterable - // (jetpack_ai_sidebar_preview_features), and this is neither — it is - // read only by the WordPress Agent notice, to decide whether its own - // action button has a working agent to open. + // Not in $features, which is public and host-filterable. 'agentNoticeActionAvailable' => self::is_agent_action_available(), ); } @@ -505,10 +501,8 @@ public static function register_toolbar_button_extension(): void { /** * Whether the legacy AI panel should point people at the WordPress Agent. * - * True once the site can send people to a working agent (the existing gate), or, - * ahead of that, once the site is eligible to turn the Agent on — the old panel is - * on its way out either way. Eligible alone may have no agent to open yet, so the - * notice's action button follows the narrower is_agent_action_available() instead. + * True once there is a working agent to open, or earlier, once the site is + * eligible to turn one on. * * @return bool */ @@ -526,15 +520,9 @@ public static function is_agent_notice_enabled(): bool { * Whether the site is eligible to turn the WordPress Agent on, whether or not * it has done so yet. * - * On WordPress.com Simple this file runs inside the wpcom process, where the - * Big Sky mu-plugin's own functions are always loaded and give the direct - * answer, including WordPress.com's separate free-trial eligibility — not - * otherwise visible to Jetpack. Atomic and self-hosted sites have no such - * functions, so they fall back to the plan check alone, which cannot see the - * free trial. - * - * Deliberately not gated on the Big_Sky class: on Simple it only loads once - * the Agent is switched on, which is after the point this check is for. + * Prefers the wpcom mu-plugin functions: the plan check cannot see the free + * trial. Not gated on the Big_Sky class, which on Simple only loads once the + * Agent is on. * * @return bool */ @@ -574,13 +562,8 @@ public static function register_agent_notice_extension(): void { /** * Whether the notice's action button has a working agent to send people to. * - * The notice itself shows earlier, once the site is merely eligible for the - * Agent — see is_agent_notice_enabled(). This is narrower: the original - * enabled gate, so the button stays hidden rather than open a chat that - * does not exist yet, or one under a different provider entirely. Reaches - * the notice through get_jetpack_ai_sidebar_preview_config(), not a - * registered extension — it is a single flag for one button, not a surface - * with its own availability and reason. + * Narrower than is_agent_notice_enabled(): an eligible site shows the notice, + * but only an enabled one has a chat to open. * * @return bool */ diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx index ab7d2a71c17c..3af8a1e173fb 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx @@ -289,8 +289,6 @@ describe( 'AiAssistantPluginSidebar', () => { jest .mocked( getFeatureAvailability ) .mockImplementation( feature => feature === AGENT_NOTICE_FEATURE ); - // The notice reads this to decide whether its action button has a - // working agent to open, the way production's server payload does. ( window as unknown as { agentsManagerData?: unknown } ).agentsManagerData = { jetpackAiSidebar: { agentNoticeActionAvailable: true }, }; diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx index 962230a2c4d4..40d45597a807 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx @@ -45,13 +45,11 @@ const DOCS_URL = getRedirectUrl( 'jetpack-ai-docs-wordpress-agent' ); // it as a side effect. const EDITOR_STORE = 'core/editor'; -// Notice.Actions lets its buttons shrink, which breaks a label mid-phrase in a -// narrow sidebar. Not shrinking sends the docs link to the next line instead. +// Keeps a label on one line in a narrow sidebar; the docs link wraps instead. const ACTION_BUTTON_STYLE = { flexShrink: 0 } as const; /** - * Where a site turns the WordPress Agent on: the WordPress.com AI tools settings - * for Simple and Atomic, matching the dashboard banner, and My Jetpack elsewhere. + * Where a site turns the WordPress Agent on, matching the wpcom dashboard banner. * * @return {string} The settings URL. */ @@ -125,9 +123,8 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic const { tracks } = useAnalytics(); const { set } = useDispatch( preferencesStore ); const eventProperties = useEventProperties( placement ); - // The setup supports opening a chat (server-side) and one has actually mounted - // (client-side) — both are needed, since eligible-but-not-yet-enabled sites hit - // this component too, and a chat mounted for a different provider is not this one. + // Both needed: eligible-only sites reach here with no chat, and a mounted + // chat may belong to another provider. const canOpenAgent = isAgentActionAvailable(); const isAgentReady = useIsWordPressAgentReady(); const isChatOnScreen = useIsWordPressAgentChatVisible(); @@ -196,8 +193,7 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic style={ ACTION_BUTTON_STYLE } onClick={ openAgent } disabled={ isChatOnScreen } - // Replaces the accessible name, so it says what the button opens as - // well as why it is disabled. + // Replaces the visible label, so it must still name the button. aria-label={ isChatOnScreen ? __( 'WordPress Agent is already open', 'jetpack' ) : undefined } @@ -211,8 +207,7 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/open-agent.ts b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/open-agent.ts index 5afb1d68d796..d50095690631 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/open-agent.ts +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/open-agent.ts @@ -18,19 +18,16 @@ export const AGENTS_MANAGER_READY_EVENT = 'agents-manager-ready'; // The Agents Manager registers this store on the shared wp.data registry. const AGENTS_MANAGER_STORE = 'automattic/agents-manager'; -// The Agents Manager injects a bare `const agentsManagerData` global rather -// than a window property; some hosts assign `window.agentsManagerData` instead. -// A bare identifier read resolves either through the scope chain, and the -// typeof guard keeps it safe when neither exists. +// Injected as a bare `const`, not a window property, so read it bare behind a +// typeof guard. declare const agentsManagerData: | { jetpackAiSidebar?: { agentNoticeActionAvailable?: boolean } } | undefined; /** - * Whether the server considers the notice's action button actionable: the site - * has a working, connected agent to open, not merely one it is eligible for. + * Whether the server reports a working, connected agent to open, not merely eligibility. * - * @return {boolean} True once there is a working agent to send people to. + * @return {boolean} True once there is an agent to open. */ export function isAgentActionAvailable(): boolean { return ( diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx index faf336be635f..61072ac8c177 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/test/index.test.tsx @@ -168,7 +168,6 @@ describe( 'WordPressAgentNotice', () => { expect( screen.queryByRole( 'button', { name: 'Open WordPress Agent' } ) ).not.toBeInTheDocument(); - // The Agent is on, so there is nothing to enable either. expect( screen.queryByRole( 'link', { name: 'Enable WordPress Agent' } ) ).not.toBeInTheDocument(); @@ -218,8 +217,6 @@ describe( 'WordPressAgentNotice', () => { 'href', 'https://wordpress.com/sites/example.wordpress.com/settings/ai-tools' ); - // In place, so the editor's own unsaved-changes prompt guards the draft - // and the notice is fresh on the way back. expect( link ).not.toHaveAttribute( 'target' ); } ); diff --git a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php index 2721b3c8db03..b24342f000cf 100644 --- a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php +++ b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php @@ -533,8 +533,7 @@ public function test_preview_disabled_without_big_sky() { } /** - * On Simple the Big_Sky class only loads once the Agent is switched on, so - * eligibility must not depend on it. Runs before any test declares the stub. + * Eligibility must not need the Big_Sky class. Runs before any test declares the stub. */ public function test_agent_notice_enabled_when_plan_eligible_without_big_sky_class() { if ( class_exists( 'Big_Sky' ) ) { @@ -1158,8 +1157,7 @@ public function test_agent_notice_ignores_the_writing_assistant_toggle() { } /** - * The old panel hides ahead of the Agent being turned on: a plan that - * qualifies for it is enough, even with the sidebar itself still off. + * An eligible plan is enough to hide the old panel, even with the sidebar off. */ public function test_agent_notice_enabled_when_plan_eligible_but_sidebar_off() { $this->skip_when_wpcomsh_is_active(); @@ -1174,8 +1172,7 @@ public function test_agent_notice_enabled_when_plan_eligible_but_sidebar_off() { } /** - * Eligible alone has no working agent yet, so the notice's action button - * must stay hidden — there is nothing for it to open. + * Eligible alone has no agent to open, so the action stays hidden. */ public function test_agent_action_unavailable_when_plan_eligible_but_sidebar_off() { $this->skip_when_wpcomsh_is_active(); @@ -1191,8 +1188,7 @@ public function test_agent_action_unavailable_when_plan_eligible_but_sidebar_off } /** - * A site whose plan does not qualify for the Agent, and has not turned it - * on either, keeps the legacy panel. + * Neither eligible nor enabled keeps the legacy panel. */ public function test_agent_notice_disabled_when_plan_ineligible_and_sidebar_off() { $this->skip_when_wpcomsh_is_active(); @@ -1206,8 +1202,7 @@ public function test_agent_notice_disabled_when_plan_ineligible_and_sidebar_off( } /** - * Plan eligibility only matters on the WordPress.com platform — a - * self-hosted site is never eligible for the Agent, however its plan reads. + * A self-hosted site is never eligible, whatever its plan says. */ public function test_agent_notice_eligibility_requires_wpcom_platform() { $this->skip_when_wpcomsh_is_active(); @@ -1221,8 +1216,7 @@ public function test_agent_notice_eligibility_requires_wpcom_platform() { } /** - * Plan eligibility still needs a supported editor surface to show the - * notice on — there is no panel to replace anywhere else. + * Eligibility still needs an editor surface with a panel to replace. */ public function test_agent_notice_eligibility_requires_supported_surface() { $this->skip_when_wpcomsh_is_active(); @@ -1285,8 +1279,7 @@ public function test_agent_notice_extension_is_declared_in_the_manifest() { } /** - * The notice's action button reads its availability from this config field, - * not a registered extension — see is_agent_action_available(). + * Test that the payload carries the agent notice action flag. */ public function test_add_agents_manager_data_exposes_agent_notice_action_available() { $this->set_block_editor_screen(); @@ -1299,8 +1292,7 @@ public function test_add_agents_manager_data_exposes_agent_notice_action_availab } /** - * A disconnected user has no chat to open, so the field must say so even - * though the rest of the payload — and the notice itself — still show. + * A disconnected user has no chat to open, even though the payload still emits. */ public function test_add_agents_manager_data_agent_notice_action_unavailable_when_disconnected() { $this->set_block_editor_screen(); From ada071b1499ea255a9bbc6db609e0c27b407540b Mon Sep 17 00:00:00 2001 From: Todd Wright Date: Wed, 9 Sep 2026 12:41:44 +1000 Subject: [PATCH 6/7] Jetpack AI: Tell the notice whether the Agent is already on The notice only knew whether Jetpack could open a chat, so a site with the Agent on but Jetpack's own sidebar gate closed was told to enable it. Add an "agent enabled" flag beside the notice flag, show the enable link only when it is off, and keep the notice hidden for disconnected users as before. The disabled open button also keeps its visible label as its accessible name, with the reason as a description, so voice control can still target it. --- .../plugins/jetpack/extensions/index.json | 1 + .../ai-sidebar/class-jetpack-ai-sidebar.php | 37 +++++++++- .../test/index.test.tsx | 17 +++++ .../wordpress-agent-notice/index.tsx | 32 ++++++--- .../test/index.test.tsx | 65 ++++++++++++++--- .../ai-sidebar/Jetpack_AI_Sidebar_Test.php | 72 +++++++++++++++++-- 6 files changed, 198 insertions(+), 26 deletions(-) diff --git a/projects/plugins/jetpack/extensions/index.json b/projects/plugins/jetpack/extensions/index.json index 2989830599f3..c5fab904a73b 100644 --- a/projects/plugins/jetpack/extensions/index.json +++ b/projects/plugins/jetpack/extensions/index.json @@ -76,6 +76,7 @@ "ai-assistant-image-extension", "ai-sidebar-toolbar-button", "ai-sidebar-agent-notice", + "ai-sidebar-agent-enabled", "ai-seo-enhancer", "ai-correct-spelling", "paypal-payment-buttons", diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php index ee9748e329cf..695af0956ff1 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php @@ -33,6 +33,7 @@ const AI_SIDEBAR_AGENT_ID = 'wp-orchestrator'; const AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION = 'ai-sidebar-toolbar-button'; const AI_SIDEBAR_AGENT_NOTICE_EXTENSION = 'ai-sidebar-agent-notice'; +const AI_SIDEBAR_AGENT_ENABLED_EXTENSION = 'ai-sidebar-agent-enabled'; /** * Initializes the Agents Manager package and registers the Jetpack AI @@ -513,7 +514,28 @@ public static function is_agent_notice_enabled(): bool { return self::is_wordpress_agent_eligible() && self::is_supported_provider_surface() - && self::has_ai_features(); + && self::has_ai_features() + && ! self::is_agents_manager_disconnected(); + } + + /** + * Whether the site has turned the WordPress Agent on, whatever Jetpack's own + * sidebar gate says. + * + * @return bool + */ + private static function is_wordpress_agent_enabled(): bool { + if ( ! ( new Host() )->is_wpcom_platform() ) { + return false; + } + + if ( function_exists( 'big_sky_is_enabled' ) ) { + // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by WPCOM's Big Sky mu-plugin; guarded by function_exists() above. + return (bool) big_sky_is_enabled(); + } + + // Off Simple the Big Sky plugin is only installed once the Agent is on. + return class_exists( 'Big_Sky' ); } /** @@ -538,7 +560,7 @@ private static function is_wordpress_agent_eligible(): bool { return big_sky_is_available_for_site() || big_sky_is_enabled(); } - return Current_Plan::supports( 'big-sky' ); + return self::is_wordpress_agent_enabled() || Current_Plan::supports( 'big-sky' ); } /** @@ -557,6 +579,17 @@ public static function register_agent_notice_extension(): void { } \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_AGENT_NOTICE_EXTENSION ); + + // Tells the notice whether to offer to enable the Agent or to open it. + if ( self::is_wordpress_agent_enabled() ) { + \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_AGENT_ENABLED_EXTENSION ); + return; + } + + \Jetpack_Gutenberg::set_extension_unavailable( + AI_SIDEBAR_AGENT_ENABLED_EXTENSION, + 'jetpack_ai_sidebar_agent_not_enabled' + ); } /** diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx index 3af8a1e173fb..f2950cd71100 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/ai-assistant-plugin-sidebar/test/index.test.tsx @@ -372,6 +372,23 @@ describe( 'AiAssistantPluginSidebar', () => { name: 'Open WordPress Agent', } ) ).not.toBeInTheDocument(); + expect( + within( screen.getByTestId( 'document-panel' ) ).getByRole( 'link', { + name: 'Enable WordPress Agent', + } ) + ).toBeInTheDocument(); + } ); + + it( 'offers to enable the Agent when the server sends no payload at all', () => { + delete ( window as unknown as { agentsManagerData?: unknown } ).agentsManagerData; + + render( ); + + expect( + within( screen.getByTestId( 'document-panel' ) ).getByRole( 'link', { + name: 'Enable WordPress Agent', + } ) + ).toBeInTheDocument(); } ); it( 'leaves the collapsed panels alone when there is no notice to show', () => { diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx index 40d45597a807..c1a7f70621b2 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/components/wordpress-agent-notice/index.tsx @@ -10,7 +10,8 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { createInterpolateElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as preferencesStore } from '@wordpress/preferences'; -import { Button, Icon, LinkButton, Notice } from '@wordpress/ui'; +import { Button, Icon, LinkButton, Notice, VisuallyHidden } from '@wordpress/ui'; +import { useId } from 'react'; /** * Internal dependencies */ @@ -36,6 +37,7 @@ import type { } from './types'; export const AGENT_NOTICE_FEATURE = 'ai-sidebar-agent-notice'; +export const AGENT_ENABLED_FEATURE = 'ai-sidebar-agent-enabled'; export const PREFERENCE_SCOPE = 'jetpack/ai-assistant'; export const DISMISSED_PREFERENCE = 'wordpressAgentNoticeDismissed'; @@ -58,7 +60,11 @@ function getEnableAgentUrl(): string { return getMyJetpackUrl( '#/overview' ); } - return `https://wordpress.com/sites/${ getSiteFragment() }/settings/ai-tools`; + const siteFragment = getSiteFragment(); + + return siteFragment + ? `https://wordpress.com/sites/${ siteFragment }/settings/ai-tools` + : 'https://wordpress.com/sites'; } function useEventProperties( @@ -128,6 +134,9 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic const canOpenAgent = isAgentActionAvailable(); const isAgentReady = useIsWordPressAgentReady(); const isChatOnScreen = useIsWordPressAgentChatVisible(); + // A site with a chat to open has the Agent on, whatever the server flag says. + const isAgentOn = canOpenAgent || getFeatureAvailability( AGENT_ENABLED_FEATURE ); + const disabledReasonId = useId(); const openAgent = () => { tracks.recordEvent( 'jetpack_big_sky_agent_notice_click', { @@ -165,7 +174,7 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic style={ { gridTemplateColumns: 'auto minmax(0, 1fr) auto' } } > - { canOpenAgent + { isAgentOn ? createInterpolateElement( // translators: is the WordPress Agent's icon. "Agent" is the label on an editor toolbar button. __( @@ -183,7 +192,8 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic ), } ) - : __( 'AI tools have moved to the WordPress Agent.', 'jetpack' ) } + : /* translators: "WordPress Agent" is a product name. */ + __( 'AI tools have moved to the WordPress Agent.', 'jetpack' ) } @@ -193,17 +203,21 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic style={ ACTION_BUTTON_STYLE } onClick={ openAgent } disabled={ isChatOnScreen } - // Replaces the visible label, so it must still name the button. - aria-label={ - isChatOnScreen ? __( 'WordPress Agent is already open', 'jetpack' ) : undefined - } + aria-describedby={ isChatOnScreen ? disabledReasonId : undefined } > { /* translators: Button that opens the WordPress Agent chat. "WordPress Agent" is a product name. */ } { __( 'Open WordPress Agent', 'jetpack' ) } ) } - { ! canOpenAgent && ( + { isChatOnScreen && ( + + { /* translators: Read out to explain why the button is disabled. "WordPress Agent" is a product name. */ } + { __( 'WordPress Agent is already open', 'jetpack' ) } + + ) } + + { ! isAgentOn && ( ( { - getFeatureAvailability: () => mockIsFeatureAvailable, + getFeatureAvailability: ( feature: string ) => + feature === 'ai-sidebar-agent-enabled' ? mockIsAgentEnabled : mockIsFeatureAvailable, } ) ); jest.mock( '../open-agent', () => ( { @@ -42,7 +45,7 @@ jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { useAnalytics: () => ( { tracks: { recordEvent: mockRecordEvent } } ), - getSiteFragment: () => 'example.wordpress.com', + getSiteFragment: () => mockSiteFragment, } ) ); jest.mock( '@automattic/jetpack-ai-client', () => ( { @@ -79,7 +82,9 @@ describe( 'WordPressAgentNotice', () => { mockIsAgentReady = true; mockIsChatOnScreen = false; mockIsFeatureAvailable = true; + mockIsAgentEnabled = true; mockCanOpenAgent = true; + mockSiteFragment = 'example.wordpress.com'; // The audience props read these server-injected globals for real. delete ( globalThis as Record< string, unknown > ).agentsManagerData; delete ( globalThis as Record< string, unknown > ).bigSkyInitialState; @@ -125,16 +130,17 @@ describe( 'WordPressAgentNotice', () => { // aria-disabled rather than the disabled attribute, so the button stays // focusable and announces why. - expect( - screen.getByRole( 'button', { name: 'WordPress Agent is already open' } ) - ).toHaveAttribute( 'aria-disabled', 'true' ); + expect( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); } ); it( 'does nothing when the disabled action is clicked', async () => { const user = userEvent.setup(); render( ); - await user.click( screen.getByRole( 'button', { name: 'WordPress Agent is already open' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ); expect( setWordPressAgentChatOpen ).not.toHaveBeenCalled(); expect( mockRecordEvent ).not.toHaveBeenCalled(); @@ -143,9 +149,10 @@ describe( 'WordPressAgentNotice', () => { it( 'says why the action is disabled, rather than only dimming it', () => { render( ); + // The visible label stays the name, so voice control can still target it. expect( - screen.getByRole( 'button', { name: 'WordPress Agent is already open' } ) - ).toBeInTheDocument(); + screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) + ).toHaveAccessibleDescription( 'WordPress Agent is already open' ); } ); } ); @@ -185,6 +192,7 @@ describe( 'WordPressAgentNotice', () => { describe( 'before the site has turned the Agent on', () => { beforeEach( () => { + mockIsAgentEnabled = false; mockCanOpenAgent = false; } ); @@ -194,7 +202,7 @@ describe( 'WordPressAgentNotice', () => { expect( screen.getByText( 'AI tools have moved to the WordPress Agent.' ) ).toBeInTheDocument(); - expect( screen.queryByText( /Look for the "Ask AI"/ ) ).not.toBeInTheDocument(); + expect( screen.queryByText( /Look for the/ ) ).not.toBeInTheDocument(); } ); it( 'offers nothing to open, even once the Agents Manager is ready', () => { @@ -233,6 +241,16 @@ describe( 'WordPressAgentNotice', () => { expect( link ).not.toHaveAttribute( 'target' ); } ); + it( 'falls back to the sites list when the site slug is unknown', () => { + mockSiteFragment = null; + render( ); + + expect( screen.getByRole( 'link', { name: 'Enable WordPress Agent' } ) ).toHaveAttribute( + 'href', + 'https://wordpress.com/sites' + ); + } ); + it( 'shows the Enable action as a plain button, without the Agent icon', () => { render( ); @@ -273,6 +291,35 @@ describe( 'WordPressAgentNotice', () => { } ); } ); + describe( 'when the Agent is on but Jetpack cannot open it', () => { + beforeEach( () => { + mockIsAgentEnabled = true; + mockCanOpenAgent = false; + } ); + + it( 'keeps the pointer to the toolbar button, which the Agent still provides', () => { + render( ); + + expect( screen.getByText( /Look for the/ ) ).toBeInTheDocument(); + } ); + + it( 'does not offer to enable what is already on', () => { + render( ); + + expect( + screen.queryByRole( 'link', { name: 'Enable WordPress Agent' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'does not offer to open a chat it cannot reach', () => { + render( ); + + expect( + screen.queryByRole( 'button', { name: 'Open WordPress Agent' } ) + ).not.toBeInTheDocument(); + } ); + } ); + it( 'links to the documentation, through the Jetpack redirect service', () => { render( ); diff --git a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php index b24342f000cf..d1e705bfbc80 100644 --- a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php +++ b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php @@ -548,6 +548,44 @@ public function test_agent_notice_enabled_when_plan_eligible_without_big_sky_cla $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); } + /** + * Without the Big_Sky class the Agent is not on, so the enabled flag stays off. + * Runs before any test declares the stub. + */ + public function test_agent_enabled_extension_unavailable_when_eligible_but_not_enabled() { + if ( class_exists( 'Big_Sky' ) ) { + $this->markTestSkipped( 'Big_Sky was declared by an earlier test in this process and cannot be undeclared.' ); + } + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $this->enable_sidebar_extension_availability_checks(); + $this->simulate_wpcom_platform(); + $this->simulate_big_sky_eligible_plan(); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + + Jetpack_AI_Sidebar::register_agent_notice_extension(); + + $this->assertTrue( \Jetpack_Gutenberg::is_available( AiAssistantPlugin\AI_SIDEBAR_AGENT_NOTICE_EXTENSION ) ); + $this->assertFalse( \Jetpack_Gutenberg::is_available( AiAssistantPlugin\AI_SIDEBAR_AGENT_ENABLED_EXTENSION ) ); + } + + /** + * Neither eligible nor enabled keeps the legacy panel. Runs before any test + * declares the Big_Sky stub, which would count as enabled. + */ + public function test_agent_notice_disabled_when_plan_ineligible_and_sidebar_off() { + if ( class_exists( 'Big_Sky' ) ) { + $this->markTestSkipped( 'Big_Sky was declared by an earlier test in this process and cannot be undeclared.' ); + } + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $this->simulate_wpcom_platform(); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); + + $this->assertFalse( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + } + /** * The preview gate is closed off the WordPress.com platform, even with Big Sky present. */ @@ -1188,29 +1226,51 @@ public function test_agent_action_unavailable_when_plan_eligible_but_sidebar_off } /** - * Neither eligible nor enabled keeps the legacy panel. + * A self-hosted site is never eligible, whatever its plan says. */ - public function test_agent_notice_disabled_when_plan_ineligible_and_sidebar_off() { + public function test_agent_notice_eligibility_requires_wpcom_platform() { $this->skip_when_wpcomsh_is_active(); $this->set_block_editor_screen(); + $this->simulate_self_hosted(); + $this->simulate_big_sky_eligible_plan(); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); + + $this->assertFalse( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + } + + /** + * With the Agent on but the sidebar gated, the notice shows with neither action. + */ + public function test_agent_notice_shows_without_actions_when_agent_enabled_but_sidebar_gated() { + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $this->enable_sidebar_extension_availability_checks(); $this->simulate_wpcom_platform(); $this->simulate_big_sky_class(); remove_all_filters( 'jetpack_ai_sidebar_enabled' ); add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); - $this->assertFalse( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + Jetpack_AI_Sidebar::register_agent_notice_extension(); + + $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + $this->assertFalse( Jetpack_AI_Sidebar::is_agent_action_available() ); + $this->assertTrue( \Jetpack_Gutenberg::is_available( AiAssistantPlugin\AI_SIDEBAR_AGENT_ENABLED_EXTENSION ) ); } /** - * A self-hosted site is never eligible, whatever its plan says. + * A disconnected user cannot enable or open the Agent, so eligibility does not show the notice. */ - public function test_agent_notice_eligibility_requires_wpcom_platform() { + public function test_agent_notice_disabled_when_plan_eligible_but_disconnected() { $this->skip_when_wpcomsh_is_active(); $this->set_block_editor_screen(); - $this->simulate_self_hosted(); + $this->simulate_wpcom_platform(); + $this->simulate_big_sky_class(); $this->simulate_big_sky_eligible_plan(); remove_all_filters( 'jetpack_ai_sidebar_enabled' ); add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); + $_SERVER['A8C_PROXIED_REQUEST'] = '1'; + add_filter( 'agents_manager_variant', array( __CLASS__, 'return_gutenberg_disconnected_variant' ) ); $this->assertFalse( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); } From 2ff32e8d092ed0efef21dd57a55c2b8cb2914d1d Mon Sep 17 00:00:00 2001 From: Todd Wright Date: Wed, 9 Sep 2026 15:16:08 +1000 Subject: [PATCH 7/7] Jetpack AI: Count the Agent as off when its own setting is off The Big Sky plugin's Settings > Writing checkbox turns the Agent off while the plugin stays active, and then there is no Agent button in the editor. Read the option alongside the class, as the plugin itself does, so the notice offers to enable the Agent rather than point at a button that is not there. An installed plugin still proves eligibility on its own. --- .../ai-sidebar/class-jetpack-ai-sidebar.php | 18 +++++----- .../ai-sidebar/Jetpack_AI_Sidebar_Test.php | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php index 695af0956ff1..ea7c68108c12 100644 --- a/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php +++ b/projects/plugins/jetpack/extensions/plugins/ai-assistant-plugin/ai-sidebar/class-jetpack-ai-sidebar.php @@ -522,20 +522,21 @@ public static function is_agent_notice_enabled(): bool { * Whether the site has turned the WordPress Agent on, whatever Jetpack's own * sidebar gate says. * + * The Big Sky plugin's own Settings > Writing checkbox can turn it off while + * the plugin stays active, so the option counts as well as the class. + * * @return bool */ private static function is_wordpress_agent_enabled(): bool { - if ( ! ( new Host() )->is_wpcom_platform() ) { + $host = new Host(); + + if ( ! $host->is_wpcom_platform() || ! class_exists( 'Big_Sky' ) ) { return false; } - if ( function_exists( 'big_sky_is_enabled' ) ) { - // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by WPCOM's Big Sky mu-plugin; guarded by function_exists() above. - return (bool) big_sky_is_enabled(); - } + $default = $host->is_wpcom_simple() ? '1' : '0'; - // Off Simple the Big Sky plugin is only installed once the Agent is on. - return class_exists( 'Big_Sky' ); + return (bool) get_option( 'big_sky_enable', $default ); } /** @@ -560,7 +561,8 @@ private static function is_wordpress_agent_eligible(): bool { return big_sky_is_available_for_site() || big_sky_is_enabled(); } - return self::is_wordpress_agent_enabled() || Current_Plan::supports( 'big-sky' ); + // Off Simple the plugin is only installed once the site qualifies. + return class_exists( 'Big_Sky' ) || Current_Plan::supports( 'big-sky' ); } /** diff --git a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php index d1e705bfbc80..aba9d52b5be1 100644 --- a/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php +++ b/projects/plugins/jetpack/tests/php/extensions/plugins/ai-sidebar/Jetpack_AI_Sidebar_Test.php @@ -1248,6 +1248,7 @@ public function test_agent_notice_shows_without_actions_when_agent_enabled_but_s $this->enable_sidebar_extension_availability_checks(); $this->simulate_wpcom_platform(); $this->simulate_big_sky_class(); + update_option( 'big_sky_enable', '1' ); remove_all_filters( 'jetpack_ai_sidebar_enabled' ); add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); @@ -1258,6 +1259,40 @@ public function test_agent_notice_shows_without_actions_when_agent_enabled_but_s $this->assertTrue( \Jetpack_Gutenberg::is_available( AiAssistantPlugin\AI_SIDEBAR_AGENT_ENABLED_EXTENSION ) ); } + /** + * The plugin's own Settings > Writing checkbox can turn the Agent off while + * the plugin stays active, and then there is no Agent button to point at. + */ + public function test_agent_notice_offers_enable_when_plugin_active_but_option_off() { + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $this->enable_sidebar_extension_availability_checks(); + $this->simulate_wpcom_platform(); + $this->simulate_big_sky_class(); + $this->simulate_big_sky_eligible_plan(); + update_option( 'big_sky_enable', '0' ); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + + Jetpack_AI_Sidebar::register_agent_notice_extension(); + + $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + $this->assertFalse( \Jetpack_Gutenberg::is_available( AiAssistantPlugin\AI_SIDEBAR_AGENT_ENABLED_EXTENSION ) ); + } + + /** + * An installed plugin proves eligibility, even with the option off and no plan data. + */ + public function test_agent_notice_enabled_when_plugin_present_but_off_and_plan_unknown() { + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $this->simulate_wpcom_platform(); + $this->simulate_big_sky_class(); + update_option( 'big_sky_enable', '0' ); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + + $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + } + /** * A disconnected user cannot enable or open the Agent, so eligibility does not show the notice. */