diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx
index 77fd9db88a83..3612e5bd0a57 100644
--- a/projects/packages/seo/_inc/app.tsx
+++ b/projects/packages/seo/_inc/app.tsx
@@ -4,8 +4,10 @@ import { __ } from '@wordpress/i18n';
import { useNavigate, useSearch } from '@wordpress/route';
import { Tabs } from '@wordpress/ui';
import getOverview from './data/get-overview';
+import { useAiForm } from './data/use-ai';
import { useSettingsForm } from './data/use-settings';
import NoticesList from './notices-list';
+import AiScreen from './screens/ai';
import ContentScreen from './screens/content';
import OverviewScreen from './screens/overview';
import SettingsScreen from './screens/settings';
@@ -15,7 +17,7 @@ import type { ContentCoverage } from './data/overview-types';
import type { FC } from 'react';
type StageSearch = Record< string, unknown > & { tab?: string };
-type SeoTab = 'overview' | 'settings' | 'content';
+type SeoTab = 'overview' | 'settings' | 'content' | 'ai';
/**
* Root of the Jetpack SEO admin app, mounted by `@wordpress/build` as the
@@ -28,9 +30,12 @@ type SeoTab = 'overview' | 'settings' | 'content';
const App: FC = () => {
const search = useSearch( { from: '/' as unknown as never, strict: false } ) as StageSearch;
const activeTab: SeoTab =
- search.tab === 'settings' || search.tab === 'content' ? search.tab : 'overview';
+ search.tab === 'settings' || search.tab === 'content' || search.tab === 'ai'
+ ? search.tab
+ : 'overview';
const navigate = useNavigate();
const settingsForm = useSettingsForm();
+ const aiForm = useAiForm();
// Coverage counts live here (above the tabs) so a Content-tab edit reflects
// on the Overview card immediately on tab switch, without a reload. Seeded
@@ -55,7 +60,7 @@ const App: FC = () => {
const onTabChange = useCallback(
( next: string | null ) => {
- if ( next !== 'overview' && next !== 'settings' && next !== 'content' ) {
+ if ( next !== 'overview' && next !== 'settings' && next !== 'content' && next !== 'ai' ) {
return;
}
navigate( {
@@ -85,6 +90,7 @@ const App: FC = () => {
{ __( 'Overview', 'jetpack-seo' ) }
{ __( 'Settings', 'jetpack-seo' ) }
{ __( 'Content', 'jetpack-seo' ) }
+ { __( 'AI', 'jetpack-seo' ) }
@@ -102,6 +108,11 @@ const App: FC = () => {
+
+
+
diff --git a/projects/packages/seo/_inc/data/ai-types.ts b/projects/packages/seo/_inc/data/ai-types.ts
new file mode 100644
index 000000000000..00befa27f81b
--- /dev/null
+++ b/projects/packages/seo/_inc/data/ai-types.ts
@@ -0,0 +1,13 @@
+// Shape of the AI tab's initial state, bootstrapped onto
+// `window.JetpackScriptData.seo.ai` (see `Initializer::get_ai_data()`).
+// The AI SEO Enhancer toggle writes through the existing `/jetpack/v4/settings`
+// endpoint (`ai_seo_enhancer_enabled`).
+
+export interface AiState {
+ enhancer: {
+ /** Whether the plan + feature filter make the SEO Enhancer available. */
+ available: boolean;
+ /** Whether the SEO Enhancer is currently enabled. */
+ enabled: boolean;
+ };
+}
diff --git a/projects/packages/seo/_inc/data/use-ai.ts b/projects/packages/seo/_inc/data/use-ai.ts
new file mode 100644
index 000000000000..b6f5ce9166f6
--- /dev/null
+++ b/projects/packages/seo/_inc/data/use-ai.ts
@@ -0,0 +1,92 @@
+import { getScriptData } from '@automattic/jetpack-script-data';
+import apiFetch from '@wordpress/api-fetch';
+import { useDispatch } from '@wordpress/data';
+import { useCallback, useMemo, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
+import type { AiState } from './ai-types';
+
+// Single snackbar id reused across a save so "Updating settings…" is replaced
+// in place by "Settings saved." (or an error) — matches the Settings tab.
+const SAVE_NOTICE_ID = 'jetpack-seo-ai-save';
+
+type SeoScriptData = {
+ seo?: {
+ ai?: AiState;
+ };
+};
+
+/**
+ * Read the AI tab state bootstrapped onto `window.JetpackScriptData.seo.ai` by
+ * the server. Synchronous — present on first paint, no request. Returns `null`
+ * if the bootstrap is missing.
+ *
+ * @return The AI state, or `null` when unavailable.
+ */
+export function getAi(): AiState | null {
+ const scriptData = getScriptData() as SeoScriptData | undefined;
+ return scriptData?.seo?.ai ?? null;
+}
+
+export interface AiForm {
+ enhancer: AiState[ 'enhancer' ] | null;
+ isSaving: boolean;
+ /** Toggle the AI SEO Enhancer and save immediately. */
+ setEnhancerEnabled: ( next: boolean ) => void;
+}
+
+/**
+ * Owns the AI tab's form state: seeds from the page bootstrap and auto-saves the
+ * AI SEO Enhancer toggle through `/jetpack/v4/settings` (the same endpoint the
+ * legacy Traffic page used). On failure the local value reverts. There's no Save
+ * button — the toggle saves on change, surfacing the shared
+ * "Updating settings…"→"Settings saved." snackbar.
+ *
+ * Lives above the tab panels (in the page root) so the value survives tab
+ * switches — the script-data bootstrap is the initial load only.
+ *
+ * @return The AI form controller.
+ */
+export function useAiForm(): AiForm {
+ const initial = useMemo( () => getAi(), [] );
+ const [ enhancer, setEnhancer ] = useState< AiState[ 'enhancer' ] | null >(
+ initial?.enhancer ?? null
+ );
+ const [ isSaving, setIsSaving ] = useState( false );
+ const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore );
+
+ const setEnhancerEnabled = useCallback(
+ ( next: boolean ) => {
+ setEnhancer( prev => ( prev ? { ...prev, enabled: next } : prev ) );
+ setIsSaving( true );
+ createInfoNotice( __( 'Updating settings…', 'jetpack-seo' ), {
+ id: SAVE_NOTICE_ID,
+ type: 'snackbar',
+ isDismissible: false,
+ } );
+ apiFetch( {
+ path: '/jetpack/v4/settings',
+ method: 'POST',
+ data: { ai_seo_enhancer_enabled: next },
+ } )
+ .then( () => {
+ createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), {
+ id: SAVE_NOTICE_ID,
+ type: 'snackbar',
+ } );
+ } )
+ .catch( ( error: { message?: string } ) => {
+ // Revert the optimistic toggle so the UI reflects the persisted value.
+ setEnhancer( prev => ( prev ? { ...prev, enabled: ! next } : prev ) );
+ createErrorNotice(
+ error?.message ?? __( 'Could not save settings. Please try again.', 'jetpack-seo' ),
+ { id: SAVE_NOTICE_ID, type: 'snackbar' }
+ );
+ } )
+ .finally( () => setIsSaving( false ) );
+ },
+ [ createInfoNotice, createSuccessNotice, createErrorNotice ]
+ );
+
+ return { enhancer, isSaving, setEnhancerEnabled };
+}
diff --git a/projects/packages/seo/_inc/screens/ai/index.tsx b/projects/packages/seo/_inc/screens/ai/index.tsx
new file mode 100644
index 000000000000..8468809b0229
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/ai/index.tsx
@@ -0,0 +1,73 @@
+import { ToggleControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { Card, CollapsibleCard, Notice } from '@wordpress/ui';
+import type { AiForm } from '../../data/use-ai';
+import type { FC } from 'react';
+
+interface Props {
+ form: AiForm;
+}
+
+/**
+ * AI tab. Hosts the AI SEO Enhancer toggle today; llms.txt and AI-crawler
+ * controls land here later (tracked separately). State + auto-save live in the
+ * `form` controller (passed from the page root so it survives tab switches);
+ * this component is the presentation.
+ *
+ * The tab itself is always shown — only the Enhancer card is plan-gated, so the
+ * tab stays a home for the free AI settings still to come.
+ *
+ * @param props - Component props.
+ * @param props.form - The AI form controller from `useAiForm`.
+ * @return The AI tab content.
+ */
+const AiScreen: FC< Props > = ( { form } ) => {
+ const { enhancer, isSaving, setEnhancerEnabled } = form;
+
+ if ( ! enhancer ) {
+ return (
+
+
+ { __( 'Unable to load AI settings.', 'jetpack-seo' ) }
+
+
+ );
+ }
+
+ // The Enhancer requires a supporting plan; when unavailable the card is
+ // hidden (parity with the legacy Traffic page). The tab stays in place for
+ // the free AI settings still to come.
+ if ( ! enhancer.available ) {
+ return (
+
+
+ { __( 'More AI tools for your SEO are on the way.', 'jetpack-seo' ) }
+
+
+ );
+ }
+
+ return (
+
+
+
+ { __( 'SEO Enhancer', 'jetpack-seo' ) }
+
+
+
+
+
+
+ );
+};
+
+export default AiScreen;
diff --git a/projects/packages/seo/changelog/add-ai-tab b/projects/packages/seo/changelog/add-ai-tab
new file mode 100644
index 000000000000..d351cdb5259e
--- /dev/null
+++ b/projects/packages/seo/changelog/add-ai-tab
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Add an AI tab to the SEO dashboard and move the AI SEO Enhancer toggle (auto-generate SEO title, description, and image alt text for new posts) onto it.
diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php
index 1ed694123fd5..efc75c7eeea3 100644
--- a/projects/packages/seo/src/class-initializer.php
+++ b/projects/packages/seo/src/class-initializer.php
@@ -237,6 +237,7 @@ public static function inject_script_data( $data ) {
$data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data();
$data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data();
+ $data[ self::SCRIPT_DATA_KEY ]['ai'] = self::get_ai_data();
return $data;
}
@@ -441,4 +442,34 @@ public static function get_settings_data() {
),
);
}
+
+ /**
+ * Build the AI tab's initial state.
+ *
+ * The AI SEO Enhancer auto-generates SEO titles/descriptions/alt-text in the
+ * editor (the generation itself is wpcom/AI-Assistant side); this exposes only
+ * its persisted on/off toggle and whether it's available. Availability mirrors
+ * the legacy Traffic page: the `ai_seo_enhancer_enabled` feature filter must be
+ * on (it still depends on AI being available) AND the site's plan must support
+ * the `ai-seo-enhancer` feature. The toggle writes through the existing
+ * `/jetpack/v4/settings` endpoint (`ai_seo_enhancer_enabled`).
+ *
+ * @return array
+ */
+ public static function get_ai_data() {
+ $filter_on = (bool) apply_filters( 'ai_seo_enhancer_enabled', true );
+
+ // Current_Plan is provided by the host Jetpack plugin, not a package
+ // dependency — guard like the Jetpack_SEO_* helpers above.
+ $plan_supports = class_exists( 'Automattic\\Jetpack\\Current_Plan' )
+ // @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded by class_exists; host plugin provides the class.
+ && \Automattic\Jetpack\Current_Plan::supports( 'ai-seo-enhancer' );
+
+ return array(
+ 'enhancer' => array(
+ 'available' => $filter_on && $plan_supports,
+ 'enabled' => (bool) get_option( 'ai_seo_enhancer_enabled', false ),
+ ),
+ );
+ }
}
diff --git a/projects/packages/seo/tests/php/InitializerTest.php b/projects/packages/seo/tests/php/InitializerTest.php
index 457e1ed0bb37..231aa37828a4 100644
--- a/projects/packages/seo/tests/php/InitializerTest.php
+++ b/projects/packages/seo/tests/php/InitializerTest.php
@@ -36,4 +36,22 @@ public function test_package_version_constant_is_defined() {
public function test_feature_filter_constant_is_defined() {
$this->assertSame( 'rsm_jetpack_seo', Initializer::FEATURE_FILTER );
}
+
+ /**
+ * The AI tab bootstrap exposes the enhancer shape the React app expects, with
+ * boolean availability/enabled. Without a plan-supporting environment the
+ * enhancer is unavailable.
+ */
+ public function test_get_ai_data_shape() {
+ $ai = Initializer::get_ai_data();
+
+ $this->assertArrayHasKey( 'enhancer', $ai );
+ $this->assertArrayHasKey( 'available', $ai['enhancer'] );
+ $this->assertArrayHasKey( 'enabled', $ai['enhancer'] );
+ $this->assertIsBool( $ai['enhancer']['available'] );
+ $this->assertIsBool( $ai['enhancer']['enabled'] );
+ // Current_Plan isn't present in the package test context, so the enhancer
+ // can't be available regardless of the feature filter.
+ $this->assertFalse( $ai['enhancer']['available'] );
+ }
}