diff --git a/projects/packages/search/changelog/add-search-ai-answers-master-gate b/projects/packages/search/changelog/add-search-ai-answers-master-gate new file mode 100644 index 000000000000..113669d6bf88 --- /dev/null +++ b/projects/packages/search/changelog/add-search-ai-answers-master-gate @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +AI Answers: Follow the Jetpack AI master switch in the search overlay and the AI Answer block — live on WordPress.com Simple, internal testing environments elsewhere ahead of release. diff --git a/projects/packages/search/changelog/update-ai-master-switch-audit b/projects/packages/search/changelog/update-ai-master-switch-audit new file mode 100644 index 000000000000..bbda550ec21c --- /dev/null +++ b/projects/packages/search/changelog/update-ai-master-switch-audit @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +AI Answers: report the saved choice and the site-wide AI state in settings, disable the controls while AI is off, and honor the host AI opt-out in the AI Answer block. Require Instant Search to enable AI Answers. The master-state reporting follows the rollout: live on WordPress.com Simple, internal testing environments elsewhere. diff --git a/projects/packages/search/jest.config.js b/projects/packages/search/jest.config.js index 453319f6ce02..b9b6b37fb0bd 100644 --- a/projects/packages/search/jest.config.js +++ b/projects/packages/search/jest.config.js @@ -27,6 +27,12 @@ module.exports = { 'tiny-lru/lib/tiny-lru.esm$': '/src/instant-search/lib/test-helpers/tiny-lru.mock.js', 'instant-search/components/gridicon': '/src/instant-search/components/gridicon/index.jsx', + // Mirror the customberg webpack resolution (tools/webpack.customberg.config.js): + // `instant-search` is an alias and `hooks/*` resolves against src/customberg. + // Exact-name hook entries so dashboard's own `hooks/*` resolution is untouched. + '^instant-search/(.*)$': '/src/instant-search/$1', + '^hooks/use-search-options$': '/src/customberg/hooks/use-search-options.js', + '^hooks/use-loading-state$': '/src/customberg/hooks/use-loading-state.js', }, moduleDirectories: [ 'node_modules', '/src/dashboard' ], setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv, '/tests/jest-globals.gui.js' ], diff --git a/projects/packages/search/src/class-ai-answers.php b/projects/packages/search/src/class-ai-answers.php index ff3790537176..4a6f72caf4c4 100644 --- a/projects/packages/search/src/class-ai-answers.php +++ b/projects/packages/search/src/class-ai-answers.php @@ -7,6 +7,10 @@ namespace Automattic\Jetpack\Search; +use Automattic\Jetpack\Constants; +use Automattic\Jetpack\Modules; +use Automattic\Jetpack\Status\Host; + /** * Registers behavior meta on the Gutenberg Guidelines CPT and exposes the * jetpack_search_ai_answers_enabled option. @@ -14,6 +18,9 @@ class AI_Answers { const BEHAVIOR_META_KEY = '_guideline_block_jetpack_search-ai-summary'; const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions'; + const AI_MODULE = 'ai'; + const AI_MASTER_OPTION = 'jetpack_ai_enabled'; + const ENABLED_OPTION = 'jetpack_search_ai_answers_enabled'; /** * Hook up meta/setting registration. @@ -84,13 +91,125 @@ public static function get_behavior_instructions() { return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' ); } + /** + * Whether the site-wide AI gates currently allow AI Answers — the reporting + * predicate. + * + * The Jetpack plugin enforces the AI master switch and the host's AI opt-out + * through the `jetpack_search_ai_answers_enabled` filter; probing the chain + * with `true` reads that verdict without depending on the plugin. Sites with + * no gate registered (e.g. standalone Search) report on. + * + * @since $$next-version$$ + * + * @return bool + */ + public static function is_master_enabled() { + // Where enforcement hasn't rolled out, report ungated so no master-off + // UI shows before the switch itself does. Remove at public launch. + if ( ! self::is_master_rollout_active() ) { + return true; + } + + // ANDed with the computed predicate so reporting can never be more + // permissive than enforcement — e.g. a Simple request where the plugin's + // filter never registered, or plugin/package version skew. + return (bool) apply_filters( 'jetpack_search_ai_answers_enabled', true ) && self::should_enforce_master(); + } + + /** + * Whether master enforcement has rolled out here — mirrors the Jetpack + * plugin's rollout scoping: Simple keeps its option contract; elsewhere + * the rollout is internal-only for now. + * + * @return bool + */ + private static function is_master_rollout_active() { + if ( ( new Host() )->is_wpcom_simple() ) { + return true; + } + + return function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment(); + } + + /** + * Whether this package should enforce the master switch — the rollout-scoped + * enforcement predicate behind the block gate. + * + * Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin — + * the source of truth, unreferenceable from standalone installs. Computed + * rather than filtered so no plugin can flip a gate that must hold. + * + * @since $$next-version$$ + * + * @return bool True when Jetpack AI is on, or when the site has no master switch. + */ + public static function should_enforce_master() { + if ( ! self::is_master_rollout_active() ) { + return true; + } + + if ( ( new Host() )->is_wpcom_simple() ) { + return (bool) get_option( self::AI_MASTER_OPTION, true ); + } + + $modules = new Modules(); + + // Without the Jetpack plugin — a standalone Jetpack Search install — the + // `ai` module is not registered, so is_active() would report false for a + // master switch that was never installed. Don't gate those sites. + if ( ! in_array( self::AI_MODULE, $modules->get_available(), true ) ) { + return true; + } + + // Availability is already proven above, so skip is_active()'s repeat intersect. + return $modules->is_active( self::AI_MODULE, false ); + } + + /** + * The stored AI Answers choice, ignoring every gate. + * + * The dashboard shows this while the master switch is off, so a saved choice + * isn't misreported back to the user as off. + * + * @since $$next-version$$ + * + * @return bool + */ + public static function is_saved_on() { + return (bool) get_option( self::ENABLED_OPTION, false ); + } + /** * Whether AI Answers is enabled for the current site. */ public static function is_enabled() { - return (bool) apply_filters( - 'jetpack_search_ai_answers_enabled', - (bool) get_option( 'jetpack_search_ai_answers_enabled', false ) - ); + $enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', self::is_saved_on() ); + + // The master gate is applied after the filter chain so it cannot be + // filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`. + return $enabled && self::should_enforce_master(); + } + + /** + * Whether the host allows AI at all — core's wp_supports_ai(), falling back + * to the WP_AI_SUPPORT constant on WordPress versions that predate it. + * Mirrors the Jetpack plugin's Jetpack_AI_Settings::host_allows_ai(). + * + * @since $$next-version$$ + * + * @return bool + */ + public static function host_allows_ai() { + if ( function_exists( 'wp_supports_ai' ) ) { + // @phan-suppress-next-line PhanUndeclaredFunction -- Guarded by function_exists() above. + return (bool) wp_supports_ai(); + } + + if ( Constants::is_defined( 'WP_AI_SUPPORT' ) ) { + return (bool) Constants::get_constant( 'WP_AI_SUPPORT' ); + } + + return true; } } diff --git a/projects/packages/search/src/class-helper.php b/projects/packages/search/src/class-helper.php index 623f5ec73f8b..5f6546e4e056 100644 --- a/projects/packages/search/src/class-helper.php +++ b/projects/packages/search/src/class-helper.php @@ -992,6 +992,7 @@ public static function generate_initial_javascript_state() { */ 'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ), 'aiAnswersEnabled' => AI_Answers::is_enabled(), + 'aiMasterEnabled' => AI_Answers::is_master_enabled(), ); /** diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php index fbbcefe51477..fb1128a086be 100644 --- a/projects/packages/search/src/class-rest-controller.php +++ b/projects/packages/search/src/class-rest-controller.php @@ -388,6 +388,26 @@ protected function validate_search_settings( $module_active, $instant_search_ena ); } + // AI Answers cannot be turned on while the site-wide Jetpack AI switch is + // off. Turning it off stays allowed, so a saved choice can still be cleared. + if ( true === $ai_answers_enabled && ! AI_Answers::is_master_enabled() ) { + return new WP_Error( + 'rest_invalid_arguments', + esc_html__( 'AI Answers cannot be enabled while Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ), + array( 'status' => 400 ) + ); + } + + // AI Answers runs inside Instant Search, so enabling it requires Instant + // Search on — either already, or turned on by this same request. + if ( true === $ai_answers_enabled && true !== $instant_search_enabled && ! $this->search_module->is_instant_search_enabled() ) { + return new WP_Error( + 'rest_invalid_arguments', + esc_html__( 'AI Answers cannot be enabled while Instant Search is off.', 'jetpack-search-pkg' ), + array( 'status' => 400 ) + ); + } + // `experience` is the canonical source of truth and writes the legacy booleans in lockstep. // Reject requests that mix it with any other settings field so callers don't silently // lose those fields — the `experience` branch in update_settings() early-returns and @@ -442,6 +462,8 @@ public function get_settings() { 'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(), 'experience' => $this->search_module->get_experience(), 'ai_answers_enabled' => AI_Answers::is_enabled(), + 'ai_answers_saved' => AI_Answers::is_saved_on(), + 'ai_master_enabled' => AI_Answers::is_master_enabled(), 'search_suggestions_enabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ), 'override_woocommerce_search_template' => Search_Blocks::woocommerce_search_template_override_enabled(), ); diff --git a/projects/packages/search/src/customberg/components/app-wrapper/index.jsx b/projects/packages/search/src/customberg/components/app-wrapper/index.jsx index 98c32916a661..0e3417b01540 100644 --- a/projects/packages/search/src/customberg/components/app-wrapper/index.jsx +++ b/projects/packages/search/src/customberg/components/app-wrapper/index.jsx @@ -68,13 +68,14 @@ export default function AppWrapper() { }; // aiAnswersEnabled + searchSuggestionsEnabled live at the top level of the - // instant-search options object (not under `overlayOptions`). Override them - // here so the preview reacts to the sidebar toggles without a save round-trip. + // options object; overridden here so the preview reacts to the sidebar. While + // the master is off a saved choice persists unenforced — preview gets false. + const { aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ]; const options = { ...window[ SERVER_OBJECT_NAME ], ...Object.fromEntries( Object.entries( { - aiAnswersEnabled, + aiAnswersEnabled: aiMasterEnabled ? aiAnswersEnabled : false, searchSuggestionsEnabled, } ).filter( ( [ , v ] ) => typeof v !== 'undefined' ) ), diff --git a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx index b32c56a655f5..6f91434b78cf 100644 --- a/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx +++ b/projects/packages/search/src/customberg/components/sidebar/sidebar-options.jsx @@ -15,7 +15,7 @@ import ColorControl from './color-control'; import ExcludedPostTypesControl from './excluded-post-types-control'; import ThemeControl from './theme-control'; -const { isFreePlan = false } = window[ SERVER_OBJECT_NAME ]; +const { isFreePlan = false, aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ]; /** * Customization/configuration tab for the sidebar. @@ -60,6 +60,17 @@ export default function SidebarOptions() { const { isLoading } = useSiteLoadingState(); const isDisabled = isSaving || isLoading; + const aiAnswersHelp = aiMasterEnabled + ? __( + 'Generate AI-powered answers to visitor queries using your site’s content.', + 'jetpack-search-pkg' + ) + : __( + 'Jetpack AI is turned off for this site. Your setting will apply again when AI is turned back on.', + 'jetpack-search-pkg', + /* dummy arg to avoid bad minification */ 0 + ); + const sortOptions = [ { label: __( 'Relevance (recommended)', 'jetpack-search-pkg' ), value: 'relevance' }, { label: __( 'Newest first', 'jetpack-search-pkg' ), value: 'newest' }, @@ -212,12 +223,9 @@ export default function SidebarOptions() { diff --git a/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx b/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx new file mode 100644 index 000000000000..d2c9763b6a42 --- /dev/null +++ b/projects/packages/search/src/customberg/components/test/app-wrapper.test.jsx @@ -0,0 +1,79 @@ +import { render } from '@testing-library/react'; +import useSearchOptions from 'hooks/use-search-options'; +import SearchApp from 'instant-search/components/search-app'; + +jest.mock( 'instant-search/components/search-app', () => jest.fn( () => null ) ); +jest.mock( 'hooks/use-search-options', () => jest.fn( () => ( {} ) ) ); +jest.mock( 'hooks/use-loading-state', () => jest.fn( () => ( { isLoading: false } ) ) ); +jest.mock( 'instant-search/store', () => ( { + __esModule: true, + default: { subscribe: jest.fn(), dispatch: jest.fn(), getState: () => ( {} ) }, +} ) ); +jest.mock( 'instant-search/lib/api', () => ( { buildFilterAggregations: () => ( {} ) } ) ); +jest.mock( 'instant-search/lib/dom', () => ( { getThemeOptions: () => ( {} ) } ) ); + +const makeServerObject = ( overrides = {} ) => ( { + webpackPublicPath: '/', + widgets: [], + widgetsOutsideOverlay: [], + overlayOptions: {}, + aiAnswersEnabled: false, + ...overrides, +} ); + +describe( 'AppWrapper AI Answers preview gating', () => { + let AppWrapper; + + const searchAppOptions = () => SearchApp.mock.calls.at( -1 )[ 0 ].options; + + beforeAll( () => { + // The module reads the server object at import time, so seed it first. + window.JetpackInstantSearchOptions = makeServerObject(); + AppWrapper = require( '../app-wrapper' ).default; + } ); + + beforeEach( () => { + SearchApp.mockClear(); + } ); + + it( 'feeds the preview the enforced value while the master is off', () => { + // A saved-on choice persists while the master is off, so the raw + // entity value can be true — the preview must not stream on it. + window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: false } ); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( false ); + } ); + + it( 'lets the sidebar toggle drive the preview while the master is on', () => { + window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: true } ); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); + + it( 'defaults to ungated when the server object predates the master flag', () => { + window.JetpackInstantSearchOptions = makeServerObject(); + useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); + + it( 'falls back to the server value when the sidebar has no local edit', () => { + window.JetpackInstantSearchOptions = makeServerObject( { + aiMasterEnabled: true, + aiAnswersEnabled: true, + } ); + useSearchOptions.mockReturnValue( {} ); + + render( ); + + expect( searchAppOptions().aiAnswersEnabled ).toBe( true ); + } ); +} ); diff --git a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx index 2305baffad0d..1a46d1824a56 100644 --- a/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx +++ b/projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx @@ -24,7 +24,24 @@ export default function AiAnswersTab() { const blogID = useSelect( select => select( STORE_ID ).getBlogId(), [] ); const siteAdminUrl = useSelect( select => select( STORE_ID ).getSiteAdminUrl(), [] ); - const { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled } = useSearchSettings(); + const { + isAiAnswersEnabled, + isInstantSearchEnabled, + isAiMasterEnabled, + isAiAnswersSaved, + setAiAnswersEnabled, + } = useSearchSettings(); + + // While the master switch is off the reported value is gated to false, so show + // the saved choice instead of misreporting it back as off. + const isToggleChecked = isAiMasterEnabled ? isAiAnswersEnabled : isAiAnswersSaved; + + // Locked while the master is off, matching the AI Features view: the saved + // choice is shown but not editable. With the master on but Instant Search + // off, the toggle stays usable only to turn an already-enabled feature off. + const isToggleDisabled = + ! isAiMasterEnabled || ( ! isInstantSearchEnabled && ! isAiAnswersEnabled ); + const isAiAnswersActive = isAiMasterEnabled && isAiAnswersEnabled && isInstantSearchEnabled; const { run: sendToCart } = useProductCheckoutWorkflow( { productSlug: 'jetpack_search', @@ -94,7 +111,20 @@ export default function AiAnswersTab() { className="jp-search-ai-answers-tab__settings-inner lg-col-span-8 md-col-span-6 sm-col-span-4" > { isLoading &&

{ __( 'Loading…', 'jetpack-search-pkg' ) }

} - { supportsInstantSearch && ! isInstantSearchEnabled && ( + { supportsInstantSearch && ! isAiMasterEnabled && ( + + + { __( 'Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ) } + + + { __( + 'Your AI Answers setting is saved and will apply again when AI is turned back on.', + 'jetpack-search-pkg' + ) } + + + ) } + { supportsInstantSearch && isAiMasterEnabled && ! isInstantSearchEnabled && ( { __( @@ -109,10 +139,10 @@ export default function AiAnswersTab() { ) } { ! isLoading && ! isUnavailable && ( @@ -124,13 +154,13 @@ export default function AiAnswersTab() { onChange={ setContent } placeholder={ DEFAULT_PERSONALITY } rows={ 10 } - disabled={ isSaving || ! isAiAnswersEnabled || ! isInstantSearchEnabled } + disabled={ isSaving || ! isAiAnswersActive } />
diff --git a/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx index 13b24e41386a..56c8b5a6a398 100644 --- a/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx +++ b/projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx @@ -81,12 +81,16 @@ const mockUpdateJetpackSettings = jest.fn(); * @param {boolean} root0.isInstantSearchEnabled - Whether instant search is enabled. * @param {boolean} root0.isFreePlan - Whether the site is on a free plan. * @param {boolean} root0.isAiAnswersEnabled - Whether AI Answers is enabled. + * @param {boolean} root0.isAiMasterEnabled - Whether the site-wide Jetpack AI switch is on. + * @param {boolean} root0.isAiAnswersSaved - The saved AI Answers choice, ignoring gates. */ function setupStore( { supportsInstantSearch = true, isInstantSearchEnabled = true, isFreePlan = false, isAiAnswersEnabled = false, + isAiMasterEnabled = true, + isAiAnswersSaved = false, } = {} ) { useDispatch.mockReturnValue( { updateJetpackSettings: mockUpdateJetpackSettings, @@ -97,6 +101,8 @@ function setupStore( { isInstantSearchEnabled: () => isInstantSearchEnabled, isFreePlan: () => isFreePlan, isAiAnswersEnabled: () => isAiAnswersEnabled, + isAiMasterEnabled: () => isAiMasterEnabled, + isAiAnswersSaved: () => isAiAnswersSaved, getBlogId: () => 1, getSiteAdminUrl: () => 'http://example.com/wp-admin/', } ) ) @@ -172,4 +178,39 @@ describe( 'AiAnswersTab', () => { const toggle = await screen.findByRole( 'checkbox' ); expect( toggle ).toBeDisabled(); } ); + + it( 'toggle is disabled when the site-wide AI switch is off', async () => { + // The back end reports ai_answers_enabled as the gated value, so it is + // false whenever the master is off — the saved choice comes separately. + setupStore( { isAiMasterEnabled: false, isAiAnswersEnabled: false, isAiAnswersSaved: true } ); + render( ); + const toggle = await screen.findByRole( 'checkbox' ); + expect( toggle ).toBeDisabled(); + } ); + + it( 'toggle keeps showing a saved choice while the site-wide AI switch is off', async () => { + setupStore( { isAiMasterEnabled: false, isAiAnswersEnabled: false, isAiAnswersSaved: true } ); + render( ); + const toggle = await screen.findByRole( 'checkbox' ); + expect( toggle ).toBeChecked(); + } ); + + it( 'explains why the toggle is unavailable when the site-wide AI switch is off', async () => { + setupStore( { isAiMasterEnabled: false } ); + render( ); + await expect( + screen.findByText( 'Jetpack AI is turned off for this site.' ) + ).resolves.toBeInTheDocument(); + expect( screen.getByText( /will apply again when AI is turned back on/ ) ).toBeInTheDocument(); + } ); + + it( 'does not explain the site-wide AI switch when it is on', async () => { + setupStore( { isAiMasterEnabled: true } ); + render( ); + await waitFor( () => { + expect( + screen.queryByText( 'Jetpack AI is turned off for this site.' ) + ).not.toBeInTheDocument(); + } ); + } ); } ); diff --git a/projects/packages/search/src/dashboard/hooks/use-search-settings.js b/projects/packages/search/src/dashboard/hooks/use-search-settings.js index 8966bb4578e0..2e7ee52ce9da 100644 --- a/projects/packages/search/src/dashboard/hooks/use-search-settings.js +++ b/projects/packages/search/src/dashboard/hooks/use-search-settings.js @@ -6,7 +6,7 @@ import { STORE_ID } from 'store'; * Provides AI Answers and Instant Search settings from the store, * along with a dispatcher for updating them. * - * @return {{ isAiAnswersEnabled: boolean, isInstantSearchEnabled: boolean, setAiAnswersEnabled: Function }} Settings state and updater. + * @return {{ isAiAnswersEnabled: boolean, isInstantSearchEnabled: boolean, isAiMasterEnabled: boolean, isAiAnswersSaved: boolean, setAiAnswersEnabled: Function }} Settings state and updater. */ export default function useSearchSettings() { const isAiAnswersEnabled = useSelect( select => select( STORE_ID ).isAiAnswersEnabled(), [] ); @@ -15,6 +15,9 @@ export default function useSearchSettings() { [] ); + const isAiMasterEnabled = useSelect( select => select( STORE_ID ).isAiMasterEnabled(), [] ); + const isAiAnswersSaved = useSelect( select => select( STORE_ID ).isAiAnswersSaved(), [] ); + const { updateJetpackSettings } = useDispatch( STORE_ID ); const setAiAnswersEnabled = useCallback( @@ -22,5 +25,11 @@ export default function useSearchSettings() { [ updateJetpackSettings ] ); - return { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled }; + return { + isAiAnswersEnabled, + isInstantSearchEnabled, + isAiMasterEnabled, + isAiAnswersSaved, + setAiAnswersEnabled, + }; } diff --git a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js index ea779b5b08c7..e058958e33d8 100644 --- a/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js +++ b/projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js @@ -22,6 +22,12 @@ const jetpackSettingSelectors = { Object.prototype.hasOwnProperty.call( state.jetpackSettings, 'reader_chat' ), isReaderChatEnabled: state => state.jetpackSettings.reader_chat, isAiAnswersEnabled: state => !! state.jetpackSettings.ai_answers_enabled, + // The stored choice, ungated — shown while the master switch is off so a + // saved setting isn't misreported back to the user as off. + isAiAnswersSaved: state => !! state.jetpackSettings.ai_answers_saved, + // Treat a missing value as on, so a back end that predates the field doesn't + // gate the toggle. + isAiMasterEnabled: state => state.jetpackSettings.ai_master_enabled !== false, isSearchSuggestionsEnabled: state => !! state.jetpackSettings.search_suggestions_enabled, isWooCommerceSearchTemplateOverrideEnabled: state => !! state.jetpackSettings.override_woocommerce_search_template, diff --git a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx index 6c8bc76ee45e..6f5b6ec6a0c0 100644 --- a/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx +++ b/projects/packages/search/src/search-blocks/blocks/ai-answer/edit.jsx @@ -11,6 +11,10 @@ * gate in render.php so authors don't insert a block that won't render. The * paid-plan flag is localized onto `window.JetpackSearchBlocksConfig` * (`supportsPaidSearch`); the source of truth is the matching PHP gate. + * + * The Jetpack AI master switch gates the block the same way (`aiMasterEnabled` + * on the same config object), and its notice wins over the upgrade prompt — + * never upsell a plan while site-wide AI is off. */ import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { Button, PanelBody, Placeholder, TextControl, ToggleControl } from '@wordpress/components'; @@ -24,10 +28,10 @@ const SAMPLE_CITATIONS = [ // Editor preview defaults to "paid" when the localized config isn't present // (e.g. a Jest harness or a non-enqueued bundle context) so existing tests // see the full preview and the gate is opt-in via an explicit `false`. -const supportsPaidSearch = () => +const configFlagOn = key => typeof window === 'undefined' || ! window.JetpackSearchBlocksConfig || - window.JetpackSearchBlocksConfig.supportsPaidSearch !== false; + window.JetpackSearchBlocksConfig[ key ] !== false; const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-block'; @@ -42,7 +46,21 @@ const UPGRADE_URL = 'https://jetpack.com/upgrade/search/?utm_source=ai-answer-bl export default function AiAnswerEdit( { attributes, setAttributes } ) { const blockProps = useBlockProps(); - if ( ! supportsPaidSearch() ) { + if ( ! configFlagOn( 'aiMasterEnabled' ) ) { + return ( +
+ +
+ ); + } + + if ( ! configFlagOn( 'supportsPaidSearch' ) ) { return (