Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.35% covered (warning)
88.35%
182 / 206
70.83% covered (warning)
70.83%
17 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Sidebar
88.78% covered (warning)
88.78%
182 / 205
70.83% covered (warning)
70.83%
17 / 24
115.41
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 maybe_enqueue_abilities_script
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.02
 get_asset_data_from_file
30.00% covered (danger)
30.00%
3 / 10
0.00% covered (danger)
0.00%
0 / 1
13.57
 get_asset_data_from_remote
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
25.85
 register_provider
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 get_ai_sidebar_asset_data
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 is_seo_suggestions_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 has_seo_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_seo_tools_usable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_jetpack_ai_sidebar_preview_enabled
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 has_enabled_sidebar_features
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 should_expose_provider
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_agents_manager_disconnected
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_jetpack_ai_sidebar_preview_config
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
11
 is_toolbar_button_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_toolbar_button_extension
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 add_agents_manager_data
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get_agents_manager_data_fields
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 enable_agents_manager_on_provider_surfaces
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_patch_jetpack_ai_sidebar_preview_data
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 get_agent_provider_upsert_script
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_block_editor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_supported_provider_surface
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 has_ai_features
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Jetpack AI Sidebar — Agents Manager integration and provider registration.
4 *
5 * Initializes the jetpack-agents-manager package so it loads the Agents
6 * Manager bundle and emits its `agentsManagerData` payload, registers Jetpack
7 * AI as an Agents Manager provider, and loads the Jetpack AI provider bundle
8 * that exposes Jetpack AI abilities in the block editor.
9 *
10 * @package automattic/jetpack
11 */
12
13namespace Automattic\Jetpack\Extensions\AiAssistantPlugin;
14
15use Automattic\Jetpack\Agents_Manager\Agents_Manager;
16use Automattic\Jetpack\Connection\Manager as Connection_Manager;
17use Automattic\Jetpack\Current_Plan;
18use Automattic\Jetpack\Modules;
19use Automattic\Jetpack\Status;
20use Automattic\Jetpack\Status\Host;
21
22// Required directly rather than relying on the plugin bootstrap: on
23// WordPress.com Simple the extension files load through wpcom's own loader
24// and load-jetpack.php never runs.
25require_once __DIR__ . '/../../../../_inc/lib/class-jetpack-ai-settings.php';
26
27const AM_ASSET_BASE_PATH                  = 'widgets.wp.com/agents-manager/';
28const AI_SIDEBAR_ASSET_TRANSIENT          = 'jetpack_ai_sidebar_asset';
29const AI_SIDEBAR_JS_URL                   = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.min.js';
30const AI_SIDEBAR_CSS_URL                  = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.css';
31const AI_SIDEBAR_RTL_CSS_URL              = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.rtl.css';
32const AI_SIDEBAR_PROVIDER_URL             = 'https://' . AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.provider.mjs';
33const AI_SIDEBAR_AGENT_ID                 = 'wp-orchestrator';
34const AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION = 'ai-sidebar-toolbar-button';
35
36/**
37 * Initializes the Agents Manager package and registers the Jetpack AI
38 * provider in the block editor.
39 */
40class Jetpack_AI_Sidebar {
41
42    /**
43     * Initialize hooks.
44     *
45     * @return void
46     */
47    public static function init(): void {
48        // Gate the whole sidebar entrypoint on the preview surface, which is
49        // itself overridable via the jetpack_ai_sidebar_enabled filter.
50        if ( ! self::is_jetpack_ai_sidebar_preview_enabled() ) {
51            return;
52        }
53
54        // Initialize the Agents Manager package so it owns loading the Agents
55        // Manager bundle and emitting the `agentsManagerData` payload. The call
56        // is idempotent: on WordPress.com and Atomic, jetpack-mu-wpcom may have
57        // already initialized it, in which case this is a no-op.
58        Agents_Manager::init();
59
60        // Register as Agents Manager provider. The filter fires inside
61        // Agents_Manager::enqueue_scripts(). Priority 20 so Jetpack loads
62        // AFTER Image Studio (priority 10).
63        add_filter( 'agents_manager_agent_providers', array( __CLASS__, 'register_provider' ), 20 );
64
65        add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( __CLASS__, 'add_agents_manager_data' ), 10, 1 );
66
67        // Ask the Agents Manager package to mount on Jetpack AI provider surfaces.
68        add_filter( 'agents_manager_enabled_in_block_editor', array( __CLASS__, 'enable_agents_manager_on_provider_surfaces' ) );
69
70        // Enqueue the IIFE bundle on supported editor surfaces — it registers
71        // Jetpack AI abilities via @wordpress/abilities, which Big Sky or the
72        // Agents Manager can discover regardless of which provider system is active.
73        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_abilities_script' ), 201 );
74
75        // Patch Jetpack AI Sidebar Preview data into agentsManagerData when the
76        // Agents Manager bundle was enqueued by an external host (Big Sky on
77        // Atomic, etc.) and the jetpack_ai_sidebar_agents_manager_data filter
78        // never fired. Priority 250 runs after both jetpack-mu-wpcom and the
79        // Agents Manager package enqueue (priority 101).
80        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_patch_jetpack_ai_sidebar_preview_data' ), 250 );
81
82        // Let editor JS know when the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
83        add_action( 'jetpack_register_gutenberg_extensions', array( __CLASS__, 'register_toolbar_button_extension' ), 99 );
84    }
85
86    // ──────────────────────────────────────────────────
87    // Jetpack AI provider bundle
88    // ──────────────────────────────────────────────────
89
90    /**
91     * Enqueue the IIFE bundle that registers Jetpack AI abilities.
92     *
93     * This runs independently of AM/provider registration so preview abilities
94     * are available even when Big Sky standalone is the active UI.
95     *
96     * @return void
97     */
98    public static function maybe_enqueue_abilities_script(): void {
99        if ( ! self::should_expose_provider() ) {
100            return;
101        }
102
103        // CIAB (next-admin) has its own AM setup — don't enqueue alongside it.
104        if ( did_action( 'next_admin_init' ) ) {
105            return;
106        }
107
108        if ( self::is_agents_manager_disconnected() ) {
109            return;
110        }
111
112        // Guard against double-enqueue (e.g. hooked multiple times).
113        if ( wp_script_is( 'jetpack-ai-provider' ) ) {
114            return;
115        }
116
117        $asset_data = self::get_ai_sidebar_asset_data();
118        if ( ! $asset_data ) {
119            return;
120        }
121
122        $version      = $asset_data['version'] ?? false;
123        $dependencies = $asset_data['dependencies'] ?? array();
124
125        wp_enqueue_script(
126            'jetpack-ai-provider',
127            AI_SIDEBAR_JS_URL,
128            $dependencies,
129            $version,
130            true
131        );
132
133        wp_enqueue_style(
134            'jetpack-ai-provider',
135            is_rtl() ? AI_SIDEBAR_RTL_CSS_URL : AI_SIDEBAR_CSS_URL,
136            array(),
137            $version
138        );
139    }
140
141    // ──────────────────────────────────────────────────
142    // Asset manifest (Image Studio pattern)
143    // ──────────────────────────────────────────────────
144
145    /**
146     * Try to read the asset manifest from the local filesystem.
147     *
148     * On WordPress.com, widgets.wp.com assets are available at ABSPATH.
149     *
150     * @param string $relative_path The relative path to the JSON file.
151     * @return array|false The decoded asset data, or false if not available locally.
152     */
153    private static function get_asset_data_from_file( string $relative_path ) {
154        $local_path = ABSPATH . $relative_path;
155        if ( ! file_exists( $local_path ) ) {
156            return false;
157        }
158
159        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local file, not a remote URL.
160        $contents = file_get_contents( $local_path );
161        if ( false === $contents ) {
162            return false;
163        }
164
165        $data = json_decode( $contents, true );
166        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
167            return false;
168        }
169
170        return $data;
171    }
172
173    /**
174     * Fetch the asset manifest via HTTP.
175     *
176     * @param string $url The URL to fetch.
177     * @return array|false The decoded asset data, or false on failure.
178     */
179    private static function get_asset_data_from_remote( string $url ) {
180        $response = wp_safe_remote_get( $url );
181        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
182            return false;
183        }
184
185        $content_type = wp_remote_retrieve_header( $response, 'content-type' );
186        if ( is_string( $content_type ) && false === stripos( $content_type, 'json' ) ) {
187            return false;
188        }
189
190        $body = wp_remote_retrieve_body( $response );
191        $data = json_decode( $body, true );
192        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
193            return false;
194        }
195
196        return $data;
197    }
198
199    // ──────────────────────────────────────────────────
200    // Provider registration
201    // ──────────────────────────────────────────────────
202
203    /**
204     * Register Jetpack AI as an Agents Manager provider.
205     *
206     * Appends the CDN-hosted ESM wrapper URL to the providers list so AM
207     * can dynamically import it. Asset enqueueing is handled separately by
208     * maybe_enqueue_abilities_script.
209     *
210     * @param array $providers Existing provider URLs.
211     * @return array Updated providers.
212     */
213    public static function register_provider( array $providers ): array {
214        // CIAB (next-admin) has AM natively — skip to avoid duplicate agents.
215        if ( did_action( 'next_admin_init' ) ) {
216            return $providers;
217        }
218
219        // The provider IIFE is enqueued on the same surfaces where the ESM
220        // wrapper is registered. Avoid registering the wrapper when AM may
221        // import it before window.__JetpackAIProvider exists.
222        if ( ! self::should_expose_provider() ) {
223            return $providers;
224        }
225
226        // The providers filter still runs for disconnected variants.
227        if ( self::is_agents_manager_disconnected() ) {
228            return $providers;
229        }
230
231        // Don't register if the IIFE bundle cannot be loaded. The ESM wrapper
232        // re-exports from window.__JetpackAIProvider at import time; if the
233        // IIFE never ran, toolProvider is still a truthy Proxy and AM would
234        // call getAbilities() on it and get undefined, breaking the merge.
235        if ( ! self::get_ai_sidebar_asset_data() ) {
236            return $providers;
237        }
238
239        // Register as AM provider via CDN-hosted ESM wrapper.
240        // AM dynamically imports this module to merge tools, suggestions, and components.
241        // No ?ver= needed — the wrapper re-exports from window.__JetpackAIProvider
242        // at import time, so its behavior always matches the loaded IIFE bundle.
243        $providers[] = AI_SIDEBAR_PROVIDER_URL;
244
245        return $providers;
246    }
247
248    /**
249     * Fetch and cache the CDN asset manifest for the AI sidebar bundle.
250     *
251     * @return array|false The decoded asset data, or false on failure.
252     */
253    private static function get_ai_sidebar_asset_data() {
254        $skip_cache = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG;
255
256        if ( ! $skip_cache ) {
257            $cached = get_transient( AI_SIDEBAR_ASSET_TRANSIENT );
258            if ( false !== $cached ) {
259                return $cached;
260            }
261        }
262
263        $json_path = AM_ASSET_BASE_PATH . 'jetpack-ai-sidebar.asset.json';
264
265        // Try local file first (available on WordPress.com).
266        $data = self::get_asset_data_from_file( $json_path );
267
268        // Fallback to remote fetch.
269        if ( false === $data ) {
270            $data = self::get_asset_data_from_remote( 'https://' . $json_path );
271        }
272
273        if ( false === $data ) {
274            // The manifest is unavailable from both the local file and the CDN
275            // — for example when the server cannot reach widgets.wp.com. Skip
276            // rather than enqueue a provider whose bundle the browser may also
277            // be unable to load, which would break the Agents Manager merge.
278            return false;
279        }
280
281        if ( ! $skip_cache ) {
282            set_transient( AI_SIDEBAR_ASSET_TRANSIENT, $data, HOUR_IN_SECONDS );
283        }
284
285        return $data;
286    }
287
288    // ──────────────────────────────────────────────────
289    // Helper methods
290    // ──────────────────────────────────────────────────
291
292    /**
293     * UI feature flag for the SEO Enhancer suggestions (SEO title and meta description).
294     *
295     * Exposed only where the suggestions can actually be used: the SEO Enhancer
296     * is not killed via its filter, the site's plan includes the Jetpack SEO
297     * feature (the suggestions write to the plan-gated SEO title and meta
298     * description fields), and SEO tools are usable on the site. Kept independent
299     * of the Optimize Title suggestion: SEO suggestions target the SEO meta fields,
300     * not the visible post title.
301     *
302     * The user-facing ai_seo_enhancer_enabled *option* is consulted since the AI
303     * settings page surfaced it as the SEO feature toggle: a switched-off feature
304     * must not offer suggestions, even user-initiated ones, and even when an
305     * external host (Big Sky, Woo) draws the sidebar.
306     *
307     * @return bool
308     */
309    private static function is_seo_suggestions_enabled(): bool {
310        return (bool) apply_filters( 'ai_seo_enhancer_enabled', true )
311            && \Jetpack_AI_Settings::is_feature_enabled( 'seo_enhancer' )
312            && self::has_seo_feature()
313            && self::is_seo_tools_usable();
314    }
315
316    /**
317     * Whether the site's plan includes the Jetpack SEO feature.
318     *
319     * Same predicate the SEO editor panel uses to decide between the SEO fields and
320     * the "Optimize SEO" upgrade nudge: extensions/plugins/seo/seo.php registers
321     * availability via Jetpack_Gutenberg::set_availability_for_plan( 'advanced-seo' ),
322     * which resolves through Current_Plan::supports(). On WordPress.com Simple and
323     * Atomic this delegates to wpcom_site_has_feature( 'advanced-seo' ) — Business
324     * and higher plans; on self-hosted sites every plan includes the feature.
325     *
326     * @return bool
327     */
328    private static function has_seo_feature(): bool {
329        return Current_Plan::supports( 'advanced-seo' );
330    }
331
332    /**
333     * Whether Jetpack SEO tools are usable on this site: SEO is not disabled via the
334     * jetpack_disable_seo_tools filter — which the seo-tools module enables itself
335     * when a conflicting SEO plugin (Yoast, AIOSEO, Rank Math, …) owns the site's
336     * SEO — and the seo-tools module is active, since the module registers the SEO
337     * meta fields the suggestions write to. On WordPress.com Simple the module always
338     * reports active, so there this reduces to the filter check.
339     *
340     * @return bool
341     */
342    private static function is_seo_tools_usable(): bool {
343        /** This filter is documented in modules/seo-tools/class-jetpack-seo-utils.php */
344        return ! apply_filters( 'jetpack_disable_seo_tools', false )
345            && ( new Modules() )->is_active( 'seo-tools' );
346    }
347
348    /**
349     * UI feature flag for the public Jetpack AI Sidebar Preview surface.
350     *
351     * Defaults to enabled only on WordPress.com platform sites (Simple or WoA)
352     * that have the Big Sky plugin present and enabled. Big Sky defaults on for
353     * Simple sites and off on WoA/Atomic. The jetpack_ai_sidebar_enabled filter
354     * is a host-level override of that default, respected by init() and every
355     * sidebar surface that gates on this method. Independently of the filter,
356     * the sidebar requires at least one of its features (writing assistant or
357     * SEO enhancer) to be enabled.
358     *
359     * @return bool
360     */
361    private static function is_jetpack_ai_sidebar_preview_enabled(): bool {
362        $host = new Host();
363
364        $enabled = false;
365        if ( $host->is_wpcom_platform() && class_exists( 'Big_Sky' ) ) {
366            $default = $host->is_wpcom_simple() ? '1' : '0';
367            $enabled = (bool) get_option( 'big_sky_enable', $default );
368        }
369
370        /**
371         * Filter to enable or disable the Jetpack AI sidebar feature.
372         *
373         * Defaults to true only on WordPress.com platform sites with Big Sky
374         * present and enabled. Acts as a host-level override that can force the
375         * sidebar on (e.g. for local development) or off, and is respected by
376         * init() and every sidebar surface. The override cannot force the
377         * sidebar on while the writing-assistant and SEO enhancer features are
378         * both off — a featureless sidebar never loads.
379         *
380         * @param bool $enabled Whether the Jetpack AI sidebar is enabled.
381         */
382        $enabled = (bool) apply_filters( 'jetpack_ai_sidebar_enabled', $enabled );
383
384        // Re-asserted after the filter so a later filter cannot surface a
385        // sidebar whose features are all off, or one blocked by the host or
386        // master gates — the gates are final, as with is_ai_enabled().
387        return $enabled
388            && \Jetpack_AI_Settings::apply_master_gates( true )
389            && self::has_enabled_sidebar_features();
390    }
391
392    /**
393     * Whether any feature the sidebar surfaces is effectively enabled.
394     *
395     * The sidebar only hosts writing-assistant and SEO suggestions; when both
396     * are off it has nothing to offer and must not load. The SEO half folds in
397     * the ai_seo_enhancer_enabled kill-switch filter, the same way every other
398     * SEO enhancer consumer resolves the effective value; the writing half gets
399     * its filter inside is_feature_enabled() (owned feature).
400     *
401     * @return bool
402     */
403    private static function has_enabled_sidebar_features(): bool {
404        return \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' )
405            || ( (bool) apply_filters( 'ai_seo_enhancer_enabled', true )
406                && \Jetpack_AI_Settings::is_feature_enabled( 'seo_enhancer' ) );
407    }
408
409    /**
410     * Whether the Jetpack AI provider bundle should be exposed for this request.
411     *
412     * This is scoped to the supported editor surfaces: post editor, page editor,
413     * and site editor. Each surface remains subject to the preview and AI feature
414     * gates.
415     *
416     * @return bool
417     */
418    private static function should_expose_provider(): bool {
419        return self::is_jetpack_ai_sidebar_preview_enabled()
420            && self::is_supported_provider_surface()
421            && self::has_ai_features();
422    }
423
424    /**
425     * Whether the Agents Manager is loading a disconnected build, which it does
426     * when the current user has no linked WordPress.com account.
427     *
428     * @return bool
429     */
430    private static function is_agents_manager_disconnected(): bool {
431        $variant = Agents_Manager::get_active_variant();
432
433        // Null means the Agents Manager is not loading at all, which is not a disconnection.
434        return is_string( $variant ) && str_contains( $variant, 'disconnected' );
435    }
436
437    /**
438     * Preview configuration consumed by the Agents Manager and Jetpack AI provider bundles.
439     *
440     * @return array Preview mode and feature availability.
441     */
442    private static function get_jetpack_ai_sidebar_preview_config(): array {
443        // Ability permission callbacks enforce server-side access for these
444        // features. The writing suggestions additionally follow the writing
445        // assistant toggle on the AI settings page — a switched-off feature
446        // must not surface suggestions even when an external host (Big Sky,
447        // Woo) draws the sidebar — and SEO requires the site-side gates below.
448        $writing_on = \Jetpack_AI_Settings::is_feature_enabled( 'writing_assistant' );
449
450        $features = array(
451            'aiEditorialReview'       => (bool) apply_filters( 'jetpack_ai_editorial_review_enabled', true ) && $writing_on,
452            'generateFeedback'        => $writing_on,
453            'proofreadContent'        => $writing_on,
454            'blockTransformations'    => $writing_on,
455            'blockToolbarButton'      => $writing_on,
456            'optimizeTitleSuggestion' => $writing_on,
457            'seoSuggestions'          => self::is_seo_suggestions_enabled(),
458            'excerptSuggestion'       => $writing_on,
459            'chatHistory'             => false,
460            'supportGuides'           => false,
461        );
462
463        /**
464         * Filter the feature set exposed in Jetpack AI Sidebar Preview.
465         *
466         * @param array $features Associative array of preview feature flags.
467         */
468        $filtered_features = apply_filters( 'jetpack_ai_sidebar_preview_features', $features );
469        $features          = is_array( $filtered_features ) ? array_merge( $features, $filtered_features ) : $features;
470
471        // Normalize the flags released here while leaving host-added values
472        // untouched. Keep the AI settings page toggles and SEO's site-side
473        // requirements final.
474        $features['aiEditorialReview']       = (bool) $features['aiEditorialReview'] && $writing_on;
475        $features['generateFeedback']        = (bool) $features['generateFeedback'] && $writing_on;
476        $features['proofreadContent']        = (bool) $features['proofreadContent'] && $writing_on;
477        $features['blockToolbarButton']      = (bool) $features['blockToolbarButton'] && $writing_on;
478        $features['optimizeTitleSuggestion'] = (bool) $features['optimizeTitleSuggestion'] && $writing_on;
479        $features['seoSuggestions']          = (bool) $features['seoSuggestions'] && self::is_seo_suggestions_enabled();
480        $features['excerptSuggestion']       = (bool) $features['excerptSuggestion'] && $writing_on;
481        // Block transformations (Translate, Change Tone, etc.) are writing features
482        // and follow the writing assistant toggle.
483        $features['blockTransformations'] = (bool) $features['blockTransformations'] && $writing_on;
484
485        return array(
486            'enabled'  => self::is_jetpack_ai_sidebar_preview_enabled(),
487            'features' => $features,
488        );
489    }
490
491    /**
492     * Whether the Jetpack AI Sidebar toolbar button replaces the legacy AI toolbar.
493     *
494     * The button is a writing entry point, so it follows the writing assistant
495     * toggle. Switching it off cannot bring the legacy toolbar back in its place:
496     * the editor renders that toolbar only while this feature is unavailable AND
497     * the AI Assistant block and its ai-assistant-support extension are
498     * registered, and both of those are themselves gated on the writing assistant.
499     *
500     * @return bool
501     */
502    public static function is_toolbar_button_enabled(): bool {
503        $preview_config = self::get_jetpack_ai_sidebar_preview_config();
504
505        return self::should_expose_provider()
506            && ! self::is_agents_manager_disconnected()
507            && true === ( $preview_config['features']['blockToolbarButton'] ?? false );
508    }
509
510    /**
511     * Register the Jetpack AI Sidebar toolbar button feature.
512     *
513     * @return void
514     */
515    public static function register_toolbar_button_extension(): void {
516        if ( ! self::is_toolbar_button_enabled() ) {
517            \Jetpack_Gutenberg::set_extension_unavailable(
518                AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION,
519                'jetpack_ai_sidebar_feature_disabled'
520            );
521            return;
522        }
523
524        \Jetpack_Gutenberg::set_extension_available( AI_SIDEBAR_TOOLBAR_BUTTON_EXTENSION );
525    }
526
527    /**
528     * Add Jetpack AI Sidebar-specific data to externally emitted Agents Manager payloads.
529     *
530     * @param mixed $data Data encoded into `agentsManagerData`.
531     * @return mixed Filtered data.
532     */
533    public static function add_agents_manager_data( $data ) {
534        if ( ! is_array( $data ) ) {
535            return $data;
536        }
537
538        $fields = self::get_agents_manager_data_fields( $data );
539        if ( ! $fields ) {
540            return $data;
541        }
542
543        // Set our fields in place, leaving the rest of $data (including agentProviders)
544        // untouched so the client-side gate can drop Jetpack AI Sidebar while keeping
545        // fallbacks such as the Big Sky provider. Hosts that need intentional overrides
546        // should use the sidebar and preview feature filters.
547        foreach ( $fields as $key => $value ) {
548            $data[ $key ] = $value;
549        }
550        return $data;
551    }
552
553    /**
554     * Fields Jetpack should add to `agentsManagerData` for the current screen.
555     *
556     * @param array $data Existing Agents Manager data.
557     * @return array
558     */
559    private static function get_agents_manager_data_fields( array $data = array() ): array {
560        if ( ! self::should_expose_provider() ) {
561            return array();
562        }
563
564        $fields = array();
565        if ( empty( $data['agentId'] ) ) {
566            $fields['agentId'] = AI_SIDEBAR_AGENT_ID;
567        }
568        $fields['jetpackAiSidebar'] = self::get_jetpack_ai_sidebar_preview_config();
569
570        return $fields;
571    }
572
573    /**
574     * Enable Agents Manager when the Jetpack AI provider can be exposed.
575     *
576     * @param mixed $enabled Existing Agents Manager block-editor gate value.
577     * @return bool
578     */
579    public static function enable_agents_manager_on_provider_surfaces( $enabled ): bool {
580        if ( $enabled ) {
581            return true;
582        }
583
584        return self::should_expose_provider();
585    }
586
587    /**
588     * Inject Jetpack AI Sidebar Preview data into an externally enqueued AM bundle.
589     *
590     * The design-intended hook is jetpack_ai_sidebar_agents_manager_data, applied
591     * by jetpack-agents-manager Agents_Manager::enqueue_scripts(). On Atomic the bundled
592     * mu-wpcom (via wpcomsh) lags this PR, so the filter never fires and the
593     * client gets agentsManagerData without our fields. This `before` script runs
594     * after the upstream `before` that declares the const (added earlier) but
595     * before the AM bundle reads it, so the field is set when AM initialises.
596     * Gives Atomic parity with Jurassic Ninja without depending on a wpcomsh
597     * redeploy.
598     *
599     * Skipped on WordPress.com Simple — wpcom's data extension owns the predicate
600     * there, including any WordPress.com-specific kill-switch override.
601     *
602     * @return void
603     */
604    public static function maybe_patch_jetpack_ai_sidebar_preview_data(): void {
605        if ( ( new Host() )->is_wpcom_simple() ) {
606            return;
607        }
608
609        // 'registered' rather than 'enqueued': wp_add_inline_script attaches to any
610        // registered handle and serializes correctly regardless of when the
611        // enqueue lands in the dependency graph.
612        if ( ! wp_script_is( 'agents-manager', 'registered' ) ) {
613            return;
614        }
615
616        // The fields getter carries the provider exposure gate.
617        $fields = self::get_agents_manager_data_fields();
618        if ( ! $fields ) {
619            return;
620        }
621
622        // The second path the provider URL can reach agentsManagerData by.
623        if ( self::is_agents_manager_disconnected() ) {
624            return;
625        }
626
627        // Build the assignments from the same field source as the data filter so the
628        // two emit paths cannot drift. agentId is guarded client-side because the
629        // externally emitted payload may already define the active agent.
630        $assignments = '';
631        foreach ( $fields as $key => $value ) {
632            $assignment_value = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
633
634            if ( 'agentId' === $key ) {
635                $assignments .= ' if ( ! agentsManagerData.agentId ) { agentsManagerData.agentId = ' . $assignment_value . '; }';
636                continue;
637            }
638
639            $assignments .= ' agentsManagerData.' . $key . ' = ' . $assignment_value . ';';
640        }
641
642        if ( self::get_ai_sidebar_asset_data() ) {
643            $assignments .= self::get_agent_provider_upsert_script();
644        }
645
646        wp_add_inline_script(
647            'agents-manager',
648            'if ( typeof agentsManagerData === "object" && agentsManagerData !== null ) {' . $assignments . ' }',
649            'before'
650        );
651    }
652
653    /**
654     * Build the inline script that upserts Jetpack AI Sidebar into agentProviders.
655     *
656     * @return string Inline JavaScript.
657     */
658    private static function get_agent_provider_upsert_script(): string {
659        $provider_url = wp_json_encode( AI_SIDEBAR_PROVIDER_URL, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP );
660
661        return ' agentsManagerData.agentProviders = Array.isArray( agentsManagerData.agentProviders ) ? agentsManagerData.agentProviders : [];'
662            . ' if ( agentsManagerData.agentProviders.indexOf( ' . $provider_url . ' ) === -1 ) { agentsManagerData.agentProviders.push( ' . $provider_url . ' ); }';
663    }
664
665    /**
666     * Check if the current screen is a block editor.
667     *
668     * @return bool
669     */
670    private static function is_block_editor(): bool {
671        if ( ! function_exists( 'get_current_screen' ) ) {
672            return false;
673        }
674
675        $screen = get_current_screen();
676        return $screen && $screen->is_block_editor();
677    }
678
679    /**
680     * Check if the current screen can consume the Jetpack AI provider bundle.
681     *
682     * @return bool
683     */
684    private static function is_supported_provider_surface(): bool {
685        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
686        if ( ! $screen instanceof \WP_Screen ) {
687            return false;
688        }
689
690        if ( 'site-editor' === $screen->base ) {
691            return true;
692        }
693
694        return self::is_block_editor()
695            && 'post' === $screen->base
696            && in_array( $screen->post_type, array( 'post', 'page' ), true );
697    }
698
699    /**
700     * Check whether AI features are available.
701     *
702     * - wpcom simple: available when Jetpack AI is enabled.
703     * - Atomic/self-hosted: requires Jetpack AI enabled, a connected owner, and non-offline mode.
704     *
705     * @return bool
706     */
707    private static function has_ai_features(): bool {
708        if ( ! \Jetpack_AI_Settings::is_ai_enabled() ) {
709            return false;
710        }
711
712        $host = new Host();
713
714        if ( $host->is_wpcom_simple() ) {
715            return true;
716        }
717
718        return ( new Connection_Manager( 'jetpack' ) )->has_connected_owner()
719            && ! ( new Status() )->is_offline_mode();
720    }
721}