Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.42% covered (success)
94.42%
237 / 251
68.18% covered (warning)
68.18%
15 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Page
96.73% covered (success)
96.73%
237 / 245
68.18% covered (warning)
68.18%
15 / 22
80
0.00% covered (danger)
0.00%
0 / 1
 should_render_wp_build
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_ai_admin_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 maybe_load_wp_build
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 add_actions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 get_page_hook
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 add_page_actions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 admin_styles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 load_agents_manager
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_agents_manager_agent_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_scheduled_tasks_provider
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 add_scheduled_tasks_data
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 is_scheduled_tasks_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 page_admin_scripts
99.09% covered (success)
99.09%
109 / 110
0.00% covered (danger)
0.00%
0 / 1
18
 get_tracks_user_data
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 is_current_user_automattician
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_search_settings_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 has_my_jetpack
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_ai_plan_info
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 is_jetpack_purchase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_wpcom_plan_purchase
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 page_render
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 * Jetpack AI admin page.
4 *
5 * Registers the "AI" submenu item under Jetpack and mounts the React-based
6 * MCP settings interface.
7 *
8 * @package automattic/jetpack
9 */
10
11use Automattic\Jetpack\Admin_UI\Admin_Menu;
12use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16use Automattic\Jetpack\Modules;
17use Automattic\Jetpack\Redirect;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Terms_Of_Service;
21use Automattic\Jetpack\Tracking;
22
23if ( ! defined( 'ABSPATH' ) ) {
24    exit( 0 );
25}
26
27require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
28require_once dirname( __DIR__ ) . '/class-jetpack-ai-settings.php';
29require_once __DIR__ . '/class-jetpack-wp-build-page.php';
30
31/**
32 * Builds the Jetpack AI admin page and its sidebar menu entry.
33 */
34class Jetpack_AI_Page {
35
36    /**
37     * The wp-build route's page id, which must not be the `jetpack-ai` menu slug.
38     *
39     * @var string
40     */
41    const WP_BUILD_PAGE_ID = 'jetpack-ai-hub';
42
43    /**
44     * Whether this request renders through wp-build.
45     *
46     * Checks the render function too: if the build output is missing, the request would
47     * otherwise get no bundle at all now that the legacy entry is gone.
48     *
49     * @since 16.3
50     *
51     * @return bool
52     */
53    public static function should_render_wp_build() {
54        return function_exists( 'jetpack_plugin_jetpack_ai_hub_wp_admin_render_page' );
55    }
56
57    /**
58     * Whether the current request targets the AI Hub admin page.
59     *
60     * @since 16.3
61     *
62     * @return bool
63     */
64    private static function is_ai_admin_request() {
65        if ( ! is_admin() ) {
66            return false;
67        }
68
69        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
70        return isset( $_GET['page'] ) && 'jetpack-ai' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
71    }
72
73    /**
74     * Load wp-build for the AI Hub page.
75     *
76     * @since 16.3
77     *
78     * @return void
79     */
80    public static function maybe_load_wp_build() {
81        if ( self::is_ai_admin_request() ) {
82            Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID );
83        }
84    }
85
86    /**
87     * Register the page and its page-specific hooks.
88     *
89     * The AI Hub owns its full React layout, so it does not need the legacy
90     * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
91     * lets WordPress.com Simple load the same page without replacing its
92     * request-wide Jetpack_Admin_Page compatibility stub.
93     */
94    public function add_actions() {
95        $is_offline_mode = ( new Status() )->is_offline_mode();
96
97        if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
98            return;
99        }
100
101        if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
102            return;
103        }
104
105        $hook = $this->get_page_hook();
106        if ( ! $hook ) {
107            return;
108        }
109
110        add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
111
112        // Preserve the standalone Jetpack page's existing base stylesheet. Simple
113        // never loaded it for the Hub because it conflicts with wpcom admin pages.
114        if ( ! ( new Host() )->is_wpcom_simple() ) {
115            add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
116        }
117
118        $this->add_page_actions( $hook );
119    }
120
121    /**
122     * Register the "AI" submenu under the Jetpack top-level menu.
123     *
124     * @return string|false Hook returned by Admin_Menu::add_menu().
125     */
126    public function get_page_hook() {
127        return Admin_Menu::add_menu(
128            // "Jetpack AI" is a product name and should not be translated.
129            'Jetpack AI',
130            'Jetpack AI',
131            'manage_options',
132            'jetpack-ai',
133            array( $this, 'render' ),
134            null,
135            array(
136                'product' => 'jetpack-ai',
137                'key'     => 'jetpack-ai',
138            )
139        );
140    }
141
142    /**
143     * Attach page-specific actions.
144     *
145     * @param string $hook The page hook returned by get_page_hook().
146     */
147    public function add_page_actions( $hook ) {
148        add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
149    }
150
151    /**
152     * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
153     */
154    public function admin_styles() {
155        $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
156
157        wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
158        wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
159        wp_style_add_data( 'jetpack-admin', 'suffix', $min );
160    }
161
162    /**
163     * Request the existing Agents Manager shell for this page.
164     */
165    public function load_agents_manager() {
166        if ( ! self::is_scheduled_tasks_enabled() ) {
167            return;
168        }
169
170        Agents_Manager::init();
171
172        add_filter( 'agents_manager_should_load', '__return_true' );
173        add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
174        add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
175        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
176    }
177
178    /**
179     * Use the generic WP Orchestrator agent in AI Hub.
180     *
181     * @return string Agent ID.
182     */
183    public function get_agents_manager_agent_id() {
184        return 'wp-orchestrator';
185    }
186
187    /**
188     * Add the AI Hub provider that supplies scheduled task starter prompts.
189     *
190     * @param array $providers Existing provider module URLs.
191     * @return array Updated provider module URLs.
192     */
193    public function add_scheduled_tasks_provider( $providers ) {
194        $providers[] = add_query_arg(
195            'ver',
196            JETPACK__VERSION,
197            plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
198        );
199
200        return $providers;
201    }
202
203    /**
204     * Customize Agents Manager's empty view for the Scheduled tasks page.
205     *
206     * @param array $data Existing Agents Manager data.
207     * @return array Updated Agents Manager data.
208     */
209    public function add_scheduled_tasks_data( $data ) {
210        $current_user = wp_get_current_user();
211
212        $data['emptyViewHeading'] = sprintf(
213            /* translators: %s: Current user's display name. */
214            __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
215            $current_user->display_name
216        );
217        $data['emptyViewHelp']                     = __( 'Got a different request? Ask away.', 'jetpack' );
218        $data['scheduledTaskEmptyViewSuggestions'] = array(
219            array(
220                'id'         => 'create-daily-reminder',
221                'label'      => __( 'Create a daily reminder', 'jetpack' ),
222                'prompt'     => __( 'Create a daily reminder', 'jetpack' ),
223                'autoSubmit' => true,
224            ),
225            array(
226                'id'         => 'draft-weekly-post',
227                'label'      => __( 'Draft a weekly post', 'jetpack' ),
228                'prompt'     => __( 'Draft a weekly post', 'jetpack' ),
229                'autoSubmit' => true,
230            ),
231            array(
232                'id'         => 'schedule-monthly-report',
233                'label'      => __( 'Schedule a monthly report', 'jetpack' ),
234                'prompt'     => __( 'Schedule a monthly report', 'jetpack' ),
235                'autoSubmit' => true,
236            ),
237        );
238
239        return $data;
240    }
241
242    /**
243     * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
244     *
245     * @since 16.2
246     *
247     * @return bool
248     */
249    private static function is_scheduled_tasks_enabled() {
250        return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
251    }
252
253    /**
254     * Enqueue scripts and styles for the AI admin page.
255     */
256    public function page_admin_scripts() {
257        // wp-build owns the route bundle and its dependencies; this handle only carries the
258        // inline settings below, so the plugin version is version enough to bust its cache.
259        $script_version = JETPACK__VERSION;
260
261        $blog_id     = Connection_Manager::get_site_id( true );
262        $status      = new Status();
263        $site_suffix = $status->get_site_suffix();
264        // Use the plain hostname for the Atomic activity log URL â€” get_site_suffix() can
265        // include '::' for subdirectory installs, which would break the URL. This matches
266        // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
267        $site_host         = wp_parse_url( home_url(), PHP_URL_HOST );
268        $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
269
270        /*
271         * On Atomic link to WPCOM activity log; on self-hosted link to the local
272         * wp-admin page, which only exists while the `activity-log` module is on.
273         * An empty URL hides the row rather than linking to an unregistered page.
274         */
275        if ( ( new Host() )->is_woa_site() ) {
276            $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
277        } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
278            $activity_log_url = admin_url( 'admin.php?page=jetpack-activity-log' );
279        } else {
280            $activity_log_url = '';
281        }
282
283        /*
284         * Link SEO settings to the dedicated Jetpack SEO page where it exists,
285         * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
286         * filter is required in addition to the cohort check: is_seo_surface_visible()
287         * alone returns true on all of wpcom-platform even while the flag is off â€”
288         * it answers only the cohort half, and page registration requires both
289         * (see packages/seo Initializer::init()).
290         */
291        $seo_settings_url          = admin_url( 'admin.php?page=jetpack-settings#/traffic' );
292        $is_internal_test          = jetpack_is_internal_testing_environment();
293        $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
294        if (
295            // The exact-symbol guard matters: the autoloader can select an older
296            // jetpack-seo copy from another plugin that has the class but not
297            // this method, and class_exists alone would then fatal here.
298            method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
299            && (bool) apply_filters( 'rsm_jetpack_seo', false )
300            && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
301        ) {
302            $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
303        }
304
305        // The route bundle is registered by wp-build; this handle exists only to carry the
306        // inline settings below, which the app reads from `window.jetpackAiSettings`.
307        wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
308        wp_enqueue_script( 'jetpack-ai-admin' );
309
310        // The Tracks sender (w.js); without it, queued events never leave the
311        // browser. Consent-gated like the other surfaces that load it.
312        $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
313        if ( $can_send_tracks ) {
314            Tracking::register_tracks_functions_scripts( true );
315        }
316
317        // Unconditional, as on the other Jetpack admin pages: the connection store
318        // reads it, and only Scheduled tasks used to need it here.
319        Connection_Initial_State::render_script( 'jetpack-ai-admin' );
320
321        $host            = new Host();
322        $has_my_jetpack  = self::has_my_jetpack();
323        $is_offline_mode = $status->is_offline_mode();
324
325        /**
326         * Filters the host-specific AI Hub configuration.
327         *
328         * @since 16.2
329         *
330         * @param array $config AI Hub host configuration.
331         */
332        $config = apply_filters(
333            'jetpack_ai_admin_config',
334            array(
335                // The Overview and Features views launch on self-hosted sites first.
336                // Keep this filterable so hosts can close them independently.
337                'showGatedViews'    => ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ),
338                'showA12sBadge'     => $host->is_woa_site() && $is_internal_test,
339                // The same verdicts the feature-settings endpoint reports. That call
340                // exists for the AI Features toggles; the notice must not wait on it.
341                'isUserConnected'   => Jetpack_AI_Settings::user_is_connected(),
342                'isConnected'       => Jetpack_AI_Settings::site_is_connected(),
343                'hostAllowsAi'      => Jetpack_AI_Settings::host_allows_ai(),
344                'masterEnabled'     => Jetpack_AI_Settings::is_master_enabled(),
345                // The route, not a flag: each one documents a different hook.
346                'masterForcedOff'   => Jetpack_AI_Settings::get_master_forced_off_route(),
347                'isOfflineMode'     => $is_offline_mode,
348                'canConnectSite'    => current_user_can( 'jetpack_connect' ),
349                // These three answer one question; a filter changing one alone leaves
350                // a label pointing at a page that is not there.
351                'hasMyJetpack'      => $has_my_jetpack,
352                'userConnectionUrl' => $has_my_jetpack
353                    ? 'admin.php?page=my-jetpack#/connection'
354                    : 'admin.php?page=jetpack-settings#/connect-user',
355                'manageUrl'         => $has_my_jetpack
356                    ? 'admin.php?page=my-jetpack#/features'
357                    : 'admin.php?page=jetpack_modules',
358                'mcpSettingsApi'    => array(
359                    'path'   => '/wpcom/v2/jetpack-ai/mcp-settings',
360                    'format' => 'jetpack',
361                ),
362            )
363        );
364
365        $show_gated_views = ! empty( $config['showGatedViews'] );
366
367        $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
368
369        $settings = array(
370            'blogId'            => $blog_id ? (int) $blog_id : 0,
371            'activityLogUrl'    => $activity_log_url,
372            'seoSettingsUrl'    => $seo_settings_url,
373            'searchSettingsUrl' => self::get_search_settings_url(),
374            'siteAdminUrl'      => admin_url(),
375            'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
376            'manageUrl'         => esc_url_raw( $config['manageUrl'] ?? '' ),
377            'hasMyJetpack'      => ! empty( $config['hasMyJetpack'] ),
378            'isConnected'       => ! empty( $config['isConnected'] ),
379            'hostAllowsAi'      => ! empty( $config['hostAllowsAi'] ),
380            'masterEnabled'     => ! empty( $config['masterEnabled'] ),
381            'masterForcedOff'   => in_array(
382                $config['masterForcedOff'] ?? '',
383                array(
384                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
385                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
386                    Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
387                ),
388                true
389            ) ? $config['masterForcedOff'] : '',
390            'isOfflineMode'     => ! empty( $config['isOfflineMode'] ),
391            'canConnectSite'    => ! empty( $config['canConnectSite'] ),
392            'apiRoot'           => esc_url_raw( rest_url() ),
393            'apiNonce'          => wp_create_nonce( 'wp_rest' ),
394            'pluginUrl'         => plugins_url( '', JETPACK__PLUGIN_FILE ),
395            // Images ship from the plugin directory, so the plugin version is what busts their cache.
396            'assetsVersion'     => JETPACK__VERSION,
397            // The redirect entry bakes in the jetpack_ai_yearly product and
398            // a post-checkout return to this page, so both can be
399            // retargeted without shipping a code change.
400            'upgradeUrl'        => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
401            // The purchase granting AI â€” the usage card only uses it to pick
402            // the right loading-skeleton shape before the usage fetch lands.
403            // Only looked up when a gated view can render the card.
404            'planName'          => $plan_info['name'],
405            'showFeaturesView'  => $show_gated_views,
406            'showA12sBadge'     => ! empty( $config['showA12sBadge'] ),
407            // The tab and its Agents Manager sidebar ship disabled by default.
408            'featureFlags'      => array(
409                Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
410            ),
411            // The usage endpoint proxies as the current user, which needs
412            // their own WordPress.com account linked â€” not just the site.
413            'isUserConnected'   => ! empty( $config['isUserConnected'] ),
414            // Tracks audience properties for the jetpack_mcp_* events, per the
415            // Tracks standards for AI product events (AIINT-586). The client
416            // sends them as the strings 'true'/'false' (AIINT-576).
417            'isA11n'            => self::is_current_user_automattician(),
418            'isTest'            => $is_internal_test,
419            // Identity for Tracks; the lookup can call WordPress.com on a
420            // cache miss, so it shares the sender's guard.
421            'tracksUserData'    => $can_send_tracks ? self::get_tracks_user_data() : null,
422            'mcpSettingsApi'    => $config['mcpSettingsApi'] ?? array(),
423        );
424
425        wp_add_inline_script(
426            'jetpack-ai-admin',
427            'var jetpackAiSettings = ' . wp_json_encode(
428                $settings,
429                JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
430            ) . ';',
431            'before'
432        );
433
434        /*
435         * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
436         * event-fire time and attaches it to every Tracks event fired from this page.
437         * Without it, JS-fired events from self-hosted sites carry no blog_id â€” the
438         * Tracks pixel cannot resolve the site â€” so the events cannot be joined to
439         * plan or site data. Mirrors Connection\Initial_State::render().
440         */
441        wp_add_inline_script(
442            'jetpack-ai-admin',
443            sprintf(
444                'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
445                absint( $blog_id )
446            ),
447            'before'
448        );
449    }
450
451    /**
452     * Connected-user identity for Tracks; null when no WordPress.com account is
453     * linked. Two keys only, so email and locale stay out of the page HTML.
454     *
455     * @return array{userid:int, username:string}|null
456     */
457    private static function get_tracks_user_data() {
458        $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
459        if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
460            return null;
461        }
462
463        return array(
464            'userid'   => (int) $identity['userid'],
465            'username' => (string) $identity['username'],
466        );
467    }
468
469    /**
470     * Whether the current user is an Automattician.
471     *
472     * Identity check for the Tracks `is_a11n` audience property â€” it answers
473     * "who is this", not "may they use the tool", so it deliberately does not
474     * consult the MCP allowlist: allowlisted external testers are not a11ns.
475     *
476     * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
477     * Self-hosted Jetpack has no platform check; there the Tracks identity of a
478     * connected user is their WordPress.com account, so the connected account's
479     * email domain is the identity signal.
480     *
481     * @return bool
482     */
483    private static function is_current_user_automattician() {
484        if ( function_exists( 'is_automattician' ) ) {
485            return (bool) is_automattician( get_current_user_id() );
486        }
487
488        $user_data = ( new Connection_Manager() )->get_connected_user_data();
489        $email     = is_array( $user_data ) && ! empty( $user_data['email'] )
490            ? strtolower( (string) $user_data['email'] )
491            : '';
492
493        return '' !== $email && '@automattic.com' === substr( $email, -15 );
494    }
495
496    /**
497     * The Search dashboard page the AI Answers row links to, or '' once a host
498     * has removed it. Removal is remove_submenu_page() on `admin_menu`, which only
499     * unsets the registry entry â€” menu_page_url() still answers for the page and
500     * wp-admin then denies it â€” so the registry is read after that hook has run.
501     *
502     * @return string
503     */
504    private static function get_search_settings_url() {
505        global $submenu;
506
507        // Search registers under the Jetpack menu, or menu-less (parent '') when
508        // `jetpack_search_should_add_search_submenu` says no; both are reachable.
509        foreach ( array( 'jetpack', '' ) as $parent ) {
510            foreach ( (array) ( $submenu[ $parent ] ?? array() ) as $item ) {
511                if ( isset( $item[2] ) && 'jetpack-search' === $item[2] ) {
512                    return admin_url( 'admin.php?page=jetpack-search#/ai-answers' );
513                }
514            }
515        }
516
517        return '';
518    }
519
520    /**
521     * Whether My Jetpack is loaded on this host.
522     *
523     * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
524     * VIP removes it from outside this codebase, where that filter cannot answer.
525     *
526     * @return bool
527     */
528    private static function has_my_jetpack() {
529        if ( ( new Host() )->is_vip_site() ) {
530            return false;
531        }
532
533        return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
534            && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
535            && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
536    }
537
538    /**
539     * Name of the purchase granting this site AI ("Jetpack Complete"), from
540     * My Jetpack's purchase data â€” its Plans section's source.
541     *
542     * @return array{name: string} An empty string when nothing paid grants AI
543     *                             or the data is unavailable.
544     */
545    private static function get_ai_plan_info() {
546        $empty = array( 'name' => '' );
547
548        if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
549            return $empty;
550        }
551
552        // The purchase lookup can make remote requests; cache the outcome
553        // (empty included) so the admin page pays that cost at most hourly.
554        $cached = get_transient( 'jetpack_ai_overview_plan_info' );
555        if ( is_array( $cached ) ) {
556            return array_merge( $empty, $cached );
557        }
558
559        // A failed lookup is not "no purchase": skip the hour-long cache so the
560        // next page load can try again instead of pinning a blank name.
561        if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
562            return $empty;
563        }
564
565        $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
566
567        // A WordPress.com site names its own plan, never a Jetpack one.
568        if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
569            $purchase = self::get_wpcom_plan_purchase();
570        }
571
572        $info = $empty;
573        if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
574            // The design shows the bare plan name ("Complete", "Business"), so
575            // trim the store names' brand prefixes; they are untranslated.
576            $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
577        }
578
579        set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
580
581        return $info;
582    }
583
584    /**
585     * Whether a purchase was bought from the Jetpack store.
586     *
587     * @param object|null $purchase Purchase from My Jetpack.
588     * @return bool
589     */
590    private static function is_jetpack_purchase( $purchase ) {
591        return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
592    }
593
594    /**
595     * The purchase behind the site's current WordPress.com plan.
596     *
597     * The plan record carries only a slug and a display name lives on purchases,
598     * so the slug is matched back to the purchase that created it.
599     *
600     * @return object|null Null when the plan or its purchase cannot be found.
601     */
602    private static function get_wpcom_plan_purchase() {
603        $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
604        $plan_slug    = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
605            ? (string) $current_plan['product_slug']
606            : '';
607
608        // An empty slug simply matches nothing below, so it needs no guard.
609        foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
610            if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
611                return $purchase;
612            }
613        }
614
615        return null;
616    }
617
618    /**
619     * Override the base render() to skip wrap_ui entirely.
620     *
621     * Wrap_ui renders the Jetpack masthead header and static footer, which
622     * duplicate the header/footer that AdminPage (React) already provides.
623     * Calling page_render() directly lets AdminPage own the full layout.
624     */
625    public function render() {
626        $this->page_render();
627    }
628
629    /**
630     * Render the page, or say why it could not be rendered.
631     *
632     * The generated wp-build page owns the markup the app mounts into.
633     */
634    public function page_render() {
635        if ( self::should_render_wp_build() ) {
636            jetpack_plugin_jetpack_ai_hub_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
637            return;
638        }
639
640        // The build output is missing; say so rather than leaving a silent blank page.
641        printf(
642            '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
643            esc_html__( 'Jetpack AI', 'jetpack' ),
644            esc_html__( 'Jetpack AI could not be loaded because its assets are missing. Reinstalling or updating the plugin usually fixes this. If it keeps happening, contact your site administrator or host.', 'jetpack' )
645        );
646    }
647}
648
649/*
650 * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
651 * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
652 * add_action() dedupes.
653 */
654add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );