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..e358c01b4cbf --- /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. The notice in its place offers to open the Agent, or to enable it where it is not yet on. 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 9bb1fb2d352d..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 @@ -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; @@ -32,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 @@ -45,6 +47,9 @@ class Jetpack_AI_Sidebar { * @return void */ public static function init(): void { + // 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 // itself overridable via the jetpack_ai_sidebar_enabled filter. if ( ! self::is_jetpack_ai_sidebar_preview_enabled() ) { @@ -81,9 +86,6 @@ public static function init(): void { // Let editor JS know when the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar. add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_toolbar_button_extension' ), 99 ); - - // Let editor JS know when the legacy AI panel can point people at the WordPress Agent. - add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_agent_notice_extension' ), 99 ); } // ────────────────────────────────────────────────── @@ -454,8 +456,10 @@ 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, + // Not in $features, which is public and host-filterable. + 'agentNoticeActionAvailable' => self::is_agent_action_available(), ); } @@ -498,13 +502,67 @@ 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 there is a working agent to open, or earlier, once the site is + * eligible to turn one on. * * @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() + && ! self::is_agents_manager_disconnected(); + } + + /** + * 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 { + $host = new Host(); + + if ( ! $host->is_wpcom_platform() || ! class_exists( 'Big_Sky' ) ) { + return false; + } + + $default = $host->is_wpcom_simple() ? '1' : '0'; + + return (bool) get_option( 'big_sky_enable', $default ); + } + + /** + * Whether the site is eligible to turn the WordPress Agent on, whether or not + * it has done so yet. + * + * 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 + */ + private static function is_wordpress_agent_eligible(): bool { + $host = new Host(); + + if ( ! $host->is_wpcom_platform() ) { + 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(); + } + + // Off Simple the plugin is only installed once the site qualifies. + return class_exists( 'Big_Sky' ) || Current_Plan::supports( 'big-sky' ); } /** @@ -523,6 +581,29 @@ 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' + ); + } + + /** + * Whether the notice's action button has a working agent to send people to. + * + * Narrower than is_agent_notice_enabled(): an eligible site shows the notice, + * but only an enabled one has a chat to open. + * + * @return bool + */ + public static function is_agent_action_available(): bool { + return self::should_expose_provider() && ! self::is_agents_manager_disconnected(); } /** 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..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 @@ -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 }, @@ -277,6 +281,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 +289,9 @@ describe( 'AiAssistantPluginSidebar', () => { jest .mocked( getFeatureAvailability ) .mockImplementation( feature => feature === AGENT_NOTICE_FEATURE ); + ( 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', () => { @@ -321,7 +329,7 @@ describe( 'AiAssistantPluginSidebar', () => { expect( within( screen.getByTestId( 'document-panel' ) ).getByRole( 'button', { - name: 'WordPress Agent', + name: 'Open WordPress Agent', } ) ).toBeInTheDocument(); } ); @@ -336,7 +344,7 @@ describe( 'AiAssistantPluginSidebar', () => { await user.click( within( screen.getByTestId( testId ) ).getByRole( 'button', { - name: 'WordPress Agent', + name: 'Open WordPress Agent', } ) ); @@ -346,6 +354,43 @@ 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: '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', () => { 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..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 @@ -3,21 +3,22 @@ */ import { useAiFeature } from '@automattic/jetpack-ai-client'; import { getRedirectUrl } from '@automattic/jetpack-components'; -import { getSiteType } from '@automattic/jetpack-script-data'; -import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; +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, VisuallyHidden } from '@wordpress/ui'; +import { useId } from 'react'; /** * Internal dependencies */ import { getFeatureAvailability } from '../../../../blocks/ai-assistant/lib/utils/get-feature-availability'; import bigSkyIcon from './big-sky-icon'; import { + isAgentActionAvailable, resumeWordPressAgentChat, setWordPressAgentChatOpen, useIsWordPressAgentChatVisible, @@ -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'; @@ -45,6 +47,26 @@ const DOCS_URL = getRedirectUrl( 'jetpack-ai-docs-wordpress-agent' ); // it as a side effect. const EDITOR_STORE = 'core/editor'; +// 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, matching the wpcom dashboard banner. + * + * @return {string} The settings URL. + */ +function getEnableAgentUrl(): string { + if ( getSiteType() === 'jetpack' ) { + return getMyJetpackUrl( '#/overview' ); + } + + const siteFragment = getSiteFragment(); + + return siteFragment + ? `https://wordpress.com/sites/${ siteFragment }/settings/ai-tools` + : 'https://wordpress.com/sites'; +} + function useEventProperties( placement: WordPressAgentNoticePlacement ): WordPressAgentNoticeEventProperties { @@ -107,17 +129,33 @@ export default function WordPressAgentNotice( { placement }: WordPressAgentNotic const { tracks } = useAnalytics(); const { set } = useDispatch( preferencesStore ); const eventProperties = useEventProperties( placement ); + // 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(); + // 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', eventProperties ); + tracks.recordEvent( 'jetpack_big_sky_agent_notice_click', { + ...eventProperties, + action: 'open', + } ); // Reset the view first, as the editor's Agent button does, so the chat // always opens on the same screen however it was last left. resumeWordPressAgentChat(); setWordPressAgentChatOpen( true ); }; + const recordEnableClick = () => { + tracks.recordEvent( 'jetpack_big_sky_agent_notice_click', { + ...eventProperties, + action: 'enable', + } ); + }; + const dismiss = () => { tracks.recordEvent( 'jetpack_big_sky_agent_notice_dismiss', eventProperties ); set( PREFERENCE_SCOPE, DISMISSED_PREFERENCE, true ); @@ -136,48 +174,62 @@ 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: ( - - ), - } - ) } + { isAgentOn + ? 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: ( + + ), + } + ) + : /* translators: "WordPress Agent" is a product name. */ + __( 'AI tools have moved to the WordPress Agent.', 'jetpack' ) } - { isAgentReady && ( + { canOpenAgent && isAgentReady && ( ) } + { isChatOnScreen && ( + + { /* translators: Read out to explain why the button is disabled. "WordPress Agent" is a product name. */ } + { __( 'WordPress Agent is already open', 'jetpack' ) } + + ) } + + { ! isAgentOn && ( + + { /* translators: Button that leads to the settings page where the WordPress Agent is turned on. "WordPress Agent" is a product name. */ } + { __( 'Enable WordPress Agent', 'jetpack' ) } + + ) } + { __( 'Learn more', 'jetpack' ) } 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 b0535f80d45d..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,6 +18,24 @@ 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'; +// 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 reports a working, connected agent to open, not merely eligibility. + * + * @return {boolean} True once there is an agent to open. + */ +export function isAgentActionAvailable(): boolean { + return ( + typeof agentsManagerData !== 'undefined' && + !! agentsManagerData?.jetpackAiSidebar?.agentNoticeActionAvailable + ); +} + type AgentsManagerSelect = { getIsOpen?: () => boolean; getIsMinimized?: () => boolean; 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 71553d7bccf3..864d4086a896 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 @@ -24,14 +24,19 @@ let mockPostType: string | undefined = 'post'; let mockIsAgentReady = true; let mockIsChatOnScreen = false; let mockIsFeatureAvailable = true; +let mockIsAgentEnabled = true; +let mockCanOpenAgent = true; +let mockSiteFragment: string | null = 'example.wordpress.com'; jest.mock( '../../../../../blocks/ai-assistant/lib/utils/get-feature-availability', () => ( { - getFeatureAvailability: () => mockIsFeatureAvailable, + getFeatureAvailability: ( feature: string ) => + feature === 'ai-sidebar-agent-enabled' ? mockIsAgentEnabled : mockIsFeatureAvailable, } ) ); jest.mock( '../open-agent', () => ( { setWordPressAgentChatOpen: jest.fn(), resumeWordPressAgentChat: jest.fn(), + isAgentActionAvailable: () => mockCanOpenAgent, useIsWordPressAgentReady: () => mockIsAgentReady, useIsWordPressAgentChatVisible: () => mockIsChatOnScreen, } ) ); @@ -40,6 +45,7 @@ jest.mock( '@wordpress/a11y', () => ( { speak: jest.fn() } ) ); jest.mock( '@automattic/jetpack-shared-extension-utils', () => ( { useAnalytics: () => ( { tracks: { recordEvent: mockRecordEvent } } ), + getSiteFragment: () => mockSiteFragment, } ) ); jest.mock( '@automattic/jetpack-ai-client', () => ( { @@ -48,6 +54,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 @@ -74,6 +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; @@ -119,7 +130,7 @@ 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( + expect( screen.getByRole( 'button', { name: 'Open WordPress Agent' } ) ).toHaveAttribute( 'aria-disabled', 'true' ); @@ -129,7 +140,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( setWordPressAgentChatOpen ).not.toHaveBeenCalled(); expect( mockRecordEvent ).not.toHaveBeenCalled(); @@ -138,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' ); } ); } ); @@ -160,7 +172,113 @@ 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(); + expect( + screen.queryByRole( 'link', { name: 'Enable WordPress Agent' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'can still be dismissed', async () => { + const user = userEvent.setup(); + render( ); + + await user.click( screen.getByRole( 'button', { name: 'Dismiss' } ) ); + + expect( isDismissed() ).toBe( true ); + } ); + } ); + + describe( 'before the site has turned the Agent on', () => { + beforeEach( () => { + mockIsAgentEnabled = false; + mockCanOpenAgent = false; + } ); + + it( 'drops the pointer to a toolbar button that is not there', () => { + render( ); + + expect( + screen.getByText( 'AI tools have moved to the WordPress Agent.' ) + ).toBeInTheDocument(); + expect( screen.queryByText( /Look for the/ ) ).not.toBeInTheDocument(); + } ); + + it( 'offers nothing to open, even once the Agents Manager is ready', () => { + mockIsAgentReady = true; + render( ); + + 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' + ); + expect( link ).not.toHaveAttribute( 'target' ); + } + ); + + 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' + ); + 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( ); + + 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', () => { + render( ); + + expect( screen.getByRole( 'link', { name: /Learn more/ } ) ).toBeInTheDocument(); } ); it( 'can still be dismissed', async () => { @@ -173,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( ); @@ -215,16 +362,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. @@ -237,7 +403,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', @@ -250,7 +416,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 = { @@ -270,7 +436,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 }; @@ -282,7 +448,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 } ); @@ -294,7 +460,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, @@ -306,7 +472,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' @@ -324,7 +490,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' ); @@ -336,7 +502,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' @@ -347,7 +513,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; 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 003617a407f1..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 @@ -6,6 +6,7 @@ */ use Automattic\Jetpack\Constants; +use Automattic\Jetpack\Current_Plan; use Automattic\Jetpack\Extensions\AiAssistantPlugin; use Automattic\Jetpack\Extensions\AiAssistantPlugin\Jetpack_AI_Sidebar; use Automattic\Jetpack\Status\Cache as Status_Cache; @@ -99,6 +100,8 @@ public function tear_down() { ( new \Automattic\Jetpack\Connection\Manager( 'jetpack' ) )->reset_connection_status(); delete_option( 'jetpack_offline_mode' ); delete_option( 'big_sky_enable' ); + delete_option( 'jetpack_active_plan' ); + $this->reset_plan_cache(); delete_option( \Jetpack_AI_Settings::MASTER_OPTION ); delete_option( 'jetpack_ai_writing_assistant_enabled' ); delete_option( 'jetpack_ai_seo_enabled' ); @@ -270,6 +273,52 @@ private function simulate_big_sky_class() { } } + /** + * Give the site a plan that supports the 'big-sky' feature, through + * Current_Plan's own API so its private static cache updates with it. + */ + private function simulate_big_sky_eligible_plan() { + Current_Plan::update_from_site_record( + array( + 'plan' => array( + 'product_slug' => 'jetpack_personal', + 'features' => array( 'active' => array( 'big-sky' ) ), + ), + ) + ); + } + + /** + * Clear Current_Plan::$active_plan_cache. A bound closure works on every + * supported PHP version, unlike ReflectionProperty::setAccessible (required + * before PHP 8.1, deprecated as of PHP 8.5). + */ + private function reset_plan_cache() { + $reset_plan_cache = Closure::bind( + /** @phan-closure-scope \Automattic\Jetpack\Current_Plan */ + static function () { + // An empty array is a cache miss for Current_Plan::get(). + self::$active_plan_cache = array(); + }, + null, + Current_Plan::class + ); + $reset_plan_cache(); + } + + /** + * Skips the current test when the suite runs with wpcomsh active. + * + * With wpcomsh loaded, wpcom_feature_exists()/wpcom_site_has_feature() exist, + * so Current_Plan::supports() resolves the 'big-sky' feature through WPCOM + * feature gating instead of the jetpack_active_plan option this suite sets up. + */ + private function skip_when_wpcomsh_is_active() { + if ( '1' === getenv( 'JETPACK_TEST_WPCOMSH' ) ) { + self::markTestSkipped( 'Plan features resolve through WPCOM feature gating when wpcomsh is active and cannot be simulated.' ); + } + } + /** * Whether the preview gate is open, observed through a public surface. * @@ -394,9 +443,9 @@ public function test_init_does_nothing_when_filter_is_false() { has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_toolbar_button_extension' ) ), 'register_toolbar_button_extension should not be hooked when filter is false.' ); - $this->assertFalse( + $this->assertNotFalse( has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_agent_notice_extension' ) ), - 'register_agent_notice_extension should not be hooked when filter is false.' + 'register_agent_notice_extension should be hooked even when filter is false: the notice decides its own availability.' ); } @@ -420,9 +469,9 @@ public function test_init_does_nothing_on_self_hosted() { has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_toolbar_button_extension' ) ), 'register_toolbar_button_extension should not be hooked when the preview gate is false.' ); - $this->assertFalse( + $this->assertNotFalse( has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_agent_notice_extension' ) ), - 'register_agent_notice_extension should not be hooked when the preview gate is false.' + 'register_agent_notice_extension should be hooked even when the preview gate is false.' ); } @@ -483,6 +532,60 @@ public function test_preview_disabled_without_big_sky() { $this->assertFalse( $this->gate_open() ); } + /** + * 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' ) ) { + $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(); + $this->simulate_big_sky_eligible_plan(); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + + $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. */ @@ -777,9 +880,9 @@ public function test_init_does_nothing_when_master_off_despite_late_filter() { has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_toolbar_button_extension' ) ), 'register_toolbar_button_extension should not be hooked when master is off.' ); - $this->assertFalse( + $this->assertNotFalse( has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_agent_notice_extension' ) ), - 'register_agent_notice_extension should not be hooked when master is off.' + 'register_agent_notice_extension should be hooked even when master is off; it checks the master itself.' ); } @@ -820,9 +923,9 @@ public function test_init_does_nothing_when_sidebar_features_are_off() { has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_toolbar_button_extension' ) ), 'register_toolbar_button_extension should not be hooked when both sidebar features are off.' ); - $this->assertFalse( + $this->assertNotFalse( has_action( 'jetpack_register_gutenberg_extensions', array( Jetpack_AI_Sidebar::class, 'register_agent_notice_extension' ) ), - 'register_agent_notice_extension should not be hooked when both sidebar features are off.' + 'register_agent_notice_extension should be hooked even when both sidebar features are off.' ); } @@ -1091,6 +1194,137 @@ public function test_agent_notice_ignores_the_writing_assistant_toggle() { $this->assertFalse( $button_enabled, 'The toolbar button should follow it, unlike the notice.' ); } + /** + * 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(); + $this->set_block_editor_screen(); + $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' ); + + $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + } + + /** + * 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(); + $this->set_block_editor_screen(); + $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' ); + + $this->assertTrue( Jetpack_AI_Sidebar::is_agent_notice_enabled(), 'The notice itself should still show.' ); + $this->assertFalse( Jetpack_AI_Sidebar::is_agent_action_available(), 'Its action should not.' ); + } + + /** + * 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(); + $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(); + update_option( 'big_sky_enable', '1' ); + remove_all_filters( 'jetpack_ai_sidebar_enabled' ); + add_filter( 'jetpack_ai_sidebar_enabled', '__return_false' ); + + 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 ) ); + } + + /** + * 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. + */ + public function test_agent_notice_disabled_when_plan_eligible_but_disconnected() { + $this->skip_when_wpcomsh_is_active(); + $this->set_block_editor_screen(); + $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() ); + } + + /** + * 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(); + $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' ); + $this->set_unsupported_admin_screen(); + + $this->assertFalse( Jetpack_AI_Sidebar::is_agent_notice_enabled() ); + } + /** * Test that the active sidebar registers the agent notice feature. */ @@ -1139,6 +1373,32 @@ public function test_agent_notice_extension_is_declared_in_the_manifest() { ); } + /** + * 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(); + $_SERVER['A8C_PROXIED_REQUEST'] = '1'; + add_filter( 'agents_manager_variant', array( __CLASS__, 'return_gutenberg_variant' ) ); + + $data = Jetpack_AI_Sidebar::add_agents_manager_data( array() ); + + $this->assertTrue( $data['jetpackAiSidebar']['agentNoticeActionAvailable'] ); + } + + /** + * 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(); + $_SERVER['A8C_PROXIED_REQUEST'] = '1'; + add_filter( 'agents_manager_variant', array( __CLASS__, 'return_gutenberg_disconnected_variant' ) ); + + $data = Jetpack_AI_Sidebar::add_agents_manager_data( array() ); + + $this->assertFalse( $data['jetpackAiSidebar']['agentNoticeActionAvailable'] ); + } + /** * Test that the toolbar button feature is available in the page editor. */