From 351dcccc8d84049ff9fbbfa18cac2c8baeb6f4ea Mon Sep 17 00:00:00 2001 From: Greg Ichneumon Brown Date: Wed, 6 May 2026 20:43:21 -0600 Subject: [PATCH 01/16] Jetpack Search AI Answers - add dashboard and front end ui. This is a squashed commit from developing the feature and multiple experiments with the interface. It also includes the original plan for building the feature. This was merged with the new experience selection logic from https://github.com/Automattic/jetpack/pull/48500 --- pnpm-lock.yaml | 9 + projects/packages/search/AGENTS.md | 54 + .../search/changelog/jps3-answers-plan | 4 + .../search/docs/plans/local-testing-setup.md | 309 ++++ .../plans/search-ai-answers-api-design.md | 74 + .../plans/search-ai-answers-plugin-design.md | 212 +++ .../plans/search-ai-answers-plugin-plan.md | 1383 +++++++++++++++++ .../docs/plans/search-ai-answers-roadmap.md | 57 + projects/packages/search/package.json | 3 + .../packages/search/src/class-ai-answers.php | 77 + projects/packages/search/src/class-helper.php | 16 + .../search/src/class-rest-controller.php | 27 +- .../packages/search/src/class-settings.php | 1 + .../components/ai-answers-tab/index.jsx | 193 +++ .../components/ai-answers-tab/style.scss | 70 + .../components/pages/dashboard-page.jsx | 169 +- .../pages/sections/plan-usage-section.jsx | 7 +- .../components/test/ai-answers-tab.test.jsx | 158 ++ .../hooks/use-ai-answers-settings.js | 81 + .../dashboard/hooks/use-search-settings.js | 26 + .../store/actions/jetpack-settings.js | 11 +- .../store/selectors/jetpack-settings.js | 1 + .../selectors/test/jetpack-settings.test.js | 15 + .../src/initializers/class-initializer.php | 2 + .../components/animated-ellipsis.jsx | 33 + .../components/answers-panel.jsx | 189 +++ .../components/answers-panel.scss | 210 +++ .../instant-search/components/search-app.jsx | 330 +++- .../components/search-controls.scss | 28 +- .../components/search-results.jsx | 74 +- .../components/search-results.scss | 34 +- .../src/instant-search/components/sidebar.jsx | 20 + .../instant-search/components/sidebar.scss | 37 + .../components/test/answers-panel.test.jsx | 83 + .../components/test/answers-sse.test.jsx | 120 ++ .../test/search-app-ai-gate.test.jsx | 38 + .../search/src/instant-search/lib/markdown.js | 126 ++ .../instant-search/lib/styles/_variables.scss | 4 + .../packages/search/tests/jest-globals.gui.js | 6 + .../dashboard/pages/dashboard-page.test.jsx | 16 +- .../search/tests/php/AI_Answers_Test.php | 56 + .../search/tests/php/REST_Controller_Test.php | 6 + .../packages/sync/changelog/jps3-answers-plan | 4 + .../sync/src/modules/class-search.php | 670 ++++---- .../tests/php/modules/Search_Module_Test.php | 74 + tools/e2e-commons/setup-specs/auth.setup.ts | 13 +- 46 files changed, 4636 insertions(+), 494 deletions(-) create mode 100644 projects/packages/search/AGENTS.md create mode 100644 projects/packages/search/changelog/jps3-answers-plan create mode 100644 projects/packages/search/docs/plans/local-testing-setup.md create mode 100644 projects/packages/search/docs/plans/search-ai-answers-api-design.md create mode 100644 projects/packages/search/docs/plans/search-ai-answers-plugin-design.md create mode 100644 projects/packages/search/docs/plans/search-ai-answers-plugin-plan.md create mode 100644 projects/packages/search/docs/plans/search-ai-answers-roadmap.md create mode 100644 projects/packages/search/src/class-ai-answers.php create mode 100644 projects/packages/search/src/dashboard/components/ai-answers-tab/index.jsx create mode 100644 projects/packages/search/src/dashboard/components/ai-answers-tab/style.scss create mode 100644 projects/packages/search/src/dashboard/components/test/ai-answers-tab.test.jsx create mode 100644 projects/packages/search/src/dashboard/hooks/use-ai-answers-settings.js create mode 100644 projects/packages/search/src/dashboard/hooks/use-search-settings.js create mode 100644 projects/packages/search/src/instant-search/components/animated-ellipsis.jsx create mode 100644 projects/packages/search/src/instant-search/components/answers-panel.jsx create mode 100644 projects/packages/search/src/instant-search/components/answers-panel.scss create mode 100644 projects/packages/search/src/instant-search/components/test/answers-panel.test.jsx create mode 100644 projects/packages/search/src/instant-search/components/test/answers-sse.test.jsx create mode 100644 projects/packages/search/src/instant-search/components/test/search-app-ai-gate.test.jsx create mode 100644 projects/packages/search/src/instant-search/lib/markdown.js create mode 100644 projects/packages/search/tests/php/AI_Answers_Test.php create mode 100644 projects/packages/sync/changelog/jps3-answers-plan create mode 100644 projects/packages/sync/tests/php/modules/Search_Module_Test.php diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 881af9c52883..bb3074654932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4208,12 +4208,18 @@ importers: '@automattic/jetpack-connection': specifier: workspace:* version: link:../../js-packages/connection + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data '@automattic/jetpack-shared-extension-utils': specifier: workspace:* version: link:../../js-packages/shared-extension-utils '@automattic/number-formatters': specifier: workspace:* version: link:../../js-packages/number-formatters + '@microsoft/fetch-event-source': + specifier: 2.0.1 + version: 2.0.1 '@wordpress/api-fetch': specifier: 7.44.0 version: 7.44.0 @@ -4344,6 +4350,9 @@ importers: '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@wordpress/browserslist-config': specifier: 6.44.0 version: 6.44.0 diff --git a/projects/packages/search/AGENTS.md b/projects/packages/search/AGENTS.md new file mode 100644 index 000000000000..ce06d3d3b27b --- /dev/null +++ b/projects/packages/search/AGENTS.md @@ -0,0 +1,54 @@ +# Jetpack Search + +## UI Components + +**Always use existing design system components instead of writing custom ones.** + +The Jetpack Search dashboard is actively migrating to standard WordPress components. When building UI, follow this priority order: + +### Priority 1: `@wordpress/ui` +The primary component library for new Jetpack work. Use for: +- `Button`, `Link` +- `Notice.Root` / `Notice.Title` / `Notice.Description` +- Tabs, dialogs, and layout primitives + +### Priority 2: `@wordpress/components` +Fallback for anything not yet in `@wordpress/ui`: +- `ToggleControl`, `TextareaControl`, `SelectControl` +- `Tabs`, `TabPanel` +- `Notice` (legacy) + +### Priority 3: `@automattic/jetpack-components` +Jetpack-specific components (upsell cards, plan badges, etc.). + +### Never reinvent +Do **not** create custom implementations of: +- Tab bars → use `@wordpress/components` `Tabs` or `@wordpress/ui` +- Buttons → use `@wordpress/ui` `Button` +- Notices/alerts → use `@wordpress/ui` `Notice` +- Toggles/checkboxes → use `@wordpress/components` `ToggleControl` + +When unsure which component to use, the `@wordpress/design-system-mcp` MCP is configured for this project — use it to look up the right component before building a custom one. + +## Data access + +- `@automattic/jetpack-shared-extension-utils` → `getSiteFragment()` for site domain +- `@automattic/jetpack-script-data` → `isWpcomPlatformSite()` for WP.com detection +- Store selectors via `useSelect( select => select( STORE_ID ).selectorName(), [] )` — always include `[]` dependency array + +## Testing + +Always run all three suites when changing search code: + +```bash +# JS tests (from projects/packages/search/) +pnpm test-scripts + +# Search PHP tests +jetpack test php packages/search -v + +# Sync PHP tests — search changes often affect the sync whitelist +jetpack test php packages/sync -v +``` + +- Mock external packages in tests; mock custom hooks (`hooks/use-*`) rather than their internals diff --git a/projects/packages/search/changelog/jps3-answers-plan b/projects/packages/search/changelog/jps3-answers-plan new file mode 100644 index 000000000000..bf49a7b929fc --- /dev/null +++ b/projects/packages/search/changelog/jps3-answers-plan @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Search: AI Answers — topic-based customization CPTs, HMAC auth token, dashboard tabs, and streaming answers panel in the instant-search overlay. diff --git a/projects/packages/search/docs/plans/local-testing-setup.md b/projects/packages/search/docs/plans/local-testing-setup.md new file mode 100644 index 000000000000..406a8a0a963c --- /dev/null +++ b/projects/packages/search/docs/plans/local-testing-setup.md @@ -0,0 +1,309 @@ +# Local Testing Setup — Jetpack Search AI Answers + +A checklist to verify your local environment can run tests and test the AI Answers feature end-to-end, including live calls to a WPcom sandbox. + +--- + +## 1. Prerequisites + +- [x ] Docker Desktop is installed and running (`docker info` returns no error) +- [x ] `jetpack` CLI is installed and on PATH: + ```bash + jetpack --version + # If missing: npm install -g @automattic/jetpack-cli (or pnpm add -g) + ``` +- [x ] Node and pnpm are available: + ```bash + node --version # 20+ + pnpm --version # 9+ + ``` +- [x ] PHP and Composer are available: + ```bash + php --version # 8.0+ + composer --version + ``` +- [x ] You're on the right branch: + ```bash + git branch # should be jps3-answers-plan + ``` + +--- + +## 2. Install Monorepo Dependencies + +- [x ] Install all JS and PHP dependencies from the monorepo root: + ```bash + pnpm install + composer install + ``` + This only needs to be done once (or after a `git pull` that changes lockfiles). + +--- + +## 3. Run Unit Tests Without Docker + +The PHP unit tests for the Search and Sync packages use WorDBless (no WordPress needed) and run locally without Docker. + +- [x ] Run Search package PHP tests: + ```bash + jetpack test php packages/search -v + ``` + Expected: all tests pass (including `AI_Answers_Test` once Task 1 is implemented). + +- [x ] Run Sync package PHP tests: + ```bash + jetpack test php packages/sync -v + ``` + Expected: all tests pass (including the CPT sync tests once Task 2 is implemented). + +- [x ] Run Search package JS tests: + ```bash + cd projects/packages/search + pnpm test-scripts + ``` + Expected: all tests pass. + +--- + +## 4. Build the Search Package + +Build is required before the Docker site can serve the updated JS bundle. + +- [x ] Build all Search JS bundles: + ```bash + cd projects/packages/search + pnpm build + ``` + Expected: `build/` directory updated with no errors. + +- [ ] For active development, use watch mode instead: + ```bash + pnpm watch + ``` + Leave this running while you work — it rebuilds on file save. + +--- + +## 5. Start Docker WordPress Environment + +- [x ] Start containers (first time or after `jetpack docker clean`): + ```bash + jetpack docker up -d + jetpack docker install + ``` + WordPress is now at [http://localhost](http://localhost). + Default credentials: `jp_docker_acct` / `jp_docker_pass` + +- [x ] If containers were already installed previously, just start them: + ```bash + jetpack docker up -d + ``` + +- [x ] Verify WordPress is reachable: + ```bash + open http://localhost/wp-admin + ``` + You should see the WP login page. + +- [x ] Verify Jetpack plugin is active (it's auto-linked from the monorepo): + ```bash + jetpack docker wp plugin list | grep jetpack + ``` + Expected: `jetpack` shows as `active`. + +--- + +## 6. Connect Jetpack to WordPress.com via Tunnel + +Jetpack requires a publicly accessible URL to handshake with WPcom. + +**If you're an Automattician**, use Jurassic Tube (preferred): + +- [ ] Follow the setup instructions at PCYsg-GJ2-p2 to install and configure Jurassic Tube if you haven't already. +- [ ] Start the tunnel: + ```bash + jetpack docker up --jurassic-tube + ``` + Note your assigned domain (e.g. `yourname.jurassictube.example`). + +**Alternative**: Use ngrok (requires a paid account for a stable subdomain): +- [ ] Start with ngrok: + ```bash + jetpack docker up --ngrok + ``` + +- [ ] Once the tunnel is running, re-install WordPress with the tunnel domain: + ```bash + jetpack docker install + ``` + (This updates `WP_SITEURL` and `WP_HOME` to the tunnel domain.) + +- [ ] Go to **Jetpack → Dashboard** in wp-admin and complete the WPcom connection flow. Sign in with your WPcom account. + +- [ ] Verify connection: + ```bash + jetpack docker wp jetpack status + ``` + Expected: `Jetpack is connected to WordPress.com`. + +--- + +## 7. Enable Instant Search + +Instant Search must be enabled for the overlay and AI Answers panel to appear. + +- [ ] Enable the Search module and Instant Search: + ```bash + jetpack docker wp option update jetpack_active_modules '["search"]' --format=json + jetpack docker wp option update instant_search_enabled 1 + ``` + +- [ ] Verify in wp-admin: **Jetpack → Search** should show the Search dashboard with "Instant Search" toggled on. + +--- + +## 8. Enable the AI Answers Feature Flag + +- [ ] Enable the AI Answers feature: + ```bash + jetpack docker wp option update jetpack_search_ai_answers_enabled 1 + ``` + +- [ ] Verify the **Behavior** and **Topics** tabs appear at **Jetpack → Search**. + +- [ ] Add a test behavior post via the Behavior tab (type any text, click Save). + +- [ ] Add a test topic via **Jetpack → Search → Topics → Add Topic**. + Use this post content as a starting point: + ``` + This topic covers questions about resetting passwords and account access. + + Example questions: + - How do I reset my password? + - I forgot my login email. + - My account is locked. + + Guidelines: + Direct users to the account recovery page. Do not speculate about lock durations. + ``` + +--- + +## 9. Point PHP→WPcom Calls to a Sandbox + +This routes Jetpack's server-side API calls (JWT issuance, sync, REST proxy) through your WPcom sandbox instead of production. + +- [ ] Create a mu-plugin file for sandbox config: + ```bash + cat > tools/docker/mu-plugins/sandbox.php << 'EOF' + is_search_enabled() ) { + add_filter( 'jetpack_sync_post_types_whitelist', array( $this, 'add_ai_answer_cpts' ) ); + add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_ai_answer_meta' ) ); +} + +public function add_ai_answer_cpts( $post_types ) { + $post_types[] = 'jp_search_behavior'; + $post_types[] = 'jetpack_search_topic'; + return $post_types; +} + +public function add_ai_answer_meta( $meta_keys ) { + $meta_keys[] = '_jstopic_keywords'; + $meta_keys[] = '_jstopic_url'; + return $meta_keys; +} +``` + +**Important**: whitelisting here controls what is *sent* to wpcom. For synced data to be *stored* in the shadow replicastore, the same CPTs and meta keys must also be whitelisted on the wpcom side. + +## Authentication — Site-Level HMAC Token + +Anonymous site visitors (not logged in to WordPress) need to reach the wpcom AI agent endpoint without a user JWT. A site-level hourly HMAC token is generated server-side in PHP and embedded in `JetpackInstantSearchOptions`: + +```php +$blog_token = Automattic\Jetpack\Connection\Tokens::get_blog_token(); +$site_id = Jetpack_Options::get_option( 'id' ); +$ai_token = hash_hmac( + 'sha256', + 'search-answers:' . $site_id . ':' . floor( time() / 3600 ), + $blog_token->secret +); +``` + +This token is added to `JetpackInstantSearchOptions.aiAnswersToken`. It rotates every hour; wpcom accepts both the current and previous hour's token to handle clock skew at rotation boundaries. + +Logged-in users use the standard Jetpack AI JWT from `/jetpack/v4/jetpack-ai-jwt` instead. The overlay checks whether a JWT is available; if so, it uses that in preference to the HMAC token. + +## Search Overlay Integration + +A new `answers-panel.jsx` component is added to `src/instant-search/components/`. It renders above the search results list when in `streaming` or `done` state. + +### Component States + +| State | Display | +|-------|---------| +| `idle` | Nothing rendered | +| `loading` | Spinner with "Finding an answer…" label | +| `streaming` | Partial answer text; tokens appended as they arrive | +| `done` | Full answer text + citation list below | +| `error` | Component hidden; standard search results shown normally | + +### Triggering the AI Request + +The AI request fires when the search query changes and is at least 3 characters long, debounced by 400 ms (matching the existing search input debounce). If a new query arrives while a stream is open, the active stream is aborted before starting a new one. + +### SSE Connection + +The overlay calls the wpcom SSE endpoint directly from the browser using `SuggestionsEventSource` from `@automattic/jetpack-ai-client`: + +```js +const source = new SuggestionsEventSource( + `https://public-api.wordpress.com/wpcom/v2/sites/${ siteId }/ai/agent/jetpack-search-answers`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${ jwtToken || aiAnswersToken }`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { query, filters, locale } ), + } +); +source.addEventListener( 'chunk', ( e ) => appendToken( e.data ) ); +source.addEventListener( 'done', ( e ) => showCitations( e.data ) ); +source.addEventListener( 'error', () => hidePanel() ); +``` + +`filters` is assembled from the current overlay filter state (post type, category, tag selections) — the same values passed to the v1.3 search call — so the AI draws from the same result set the visitor sees. + +## Feature Flag + +`jetpack_search_ai_answers_enabled` gates the entire AI answers feature: + +```php +apply_filters( 'jetpack_search_ai_answers_enabled', get_option( 'jetpack_search_ai_answers_enabled', false ) ); +``` + +When `false`: +- Admin UI section is hidden +- `aiAnswersToken` is not embedded in overlay options +- `answers-panel.jsx` renders nothing; no SSE connections are made + +## Admin UI + +The Jetpack Search admin page gains a tab bar at the top, matching the style used by the Stats page (plain text tabs, blue underline on the active tab, horizontal rule below). Tabs are rendered as `` or equivalent router links so the active tab survives a page reload. + +Initial tabs: + +``` +Overview | Behavior | Topics +``` + +Future: an **Analytics** tab will be added and will become the default. The current **Overview** content (billing/usage) will remain but yield the default position to Analytics. + +### Overview Tab (existing page content) + +The current Jetpack Search dashboard — usage meters, search preview, record/request counts. No changes to existing content; it is moved inside the tab container. + +### Behavior Tab + +Block editor view of the single `jp_search_behavior` post. Descriptive text above the editor: + +> "Describe how the AI should respond to visitor questions. List the topics your site covers so the AI can classify queries. Example: 'Focus on product-related questions. Topics: Shipping, Returns, Account Access, Billing.'" + +Saving auto-publishes (or updates) the single behavior post. The admin UI prevents creating a second behavior post. + +AI request usage meter sits at the bottom of this tab: current-month count vs. the 500-request monthly limit (pulled from the wpcom quota API). When usage approaches or exceeds the limit a callout is shown with an upgrade link. + +### Topics Tab + +Standard WP list table showing all `jetpack_search_topic` posts with columns: Topic Name, Keywords, Last Modified. Row actions: Edit (opens post editor), Delete. + +"Add Topic" button opens the block editor for a new `jetpack_search_topic` post. The editor sidebar panel exposes the `_jstopic_keywords` and `_jstopic_url` fields. A template hint in the editor body guides the author through the expected structure (description, example questions, guidelines, optional pre-written content). + +Below the list table, a **Suggested Topics** section shows top search queries with high volume and low click-through rate (from the wpcom search analytics API). Each suggestion shows the query string and count, with a "Create Topic" button that pre-populates the topic editor with the query as an example question. Multiple suggestions can be selected to seed a single topic at once. + +## Future: Interactivity API Block + +When Jetpack Search 3.0 (Interactivity API block platform, PR #48155) ships, the AI answers feature will be extracted into a `jetpack/search-answers` block that subscribes to the shared `jetpack-search` Interactivity API store's `query` state and renders the answers panel inline within the block-based search UI. + +The HMAC token auth, CPT sync, and wpcom agent endpoint designed here are all block-agnostic and carry forward unchanged into that implementation. diff --git a/projects/packages/search/docs/plans/search-ai-answers-plugin-plan.md b/projects/packages/search/docs/plans/search-ai-answers-plugin-plan.md new file mode 100644 index 000000000000..0b26d15a2a5b --- /dev/null +++ b/projects/packages/search/docs/plans/search-ai-answers-plugin-plan.md @@ -0,0 +1,1383 @@ +# Jetpack Search AI Answers — WordPress Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the WP plugin side of Jetpack Search AI Answers: two CPTs, sync, an HMAC auth token, a tabbed admin UI, and an AI answers panel in the instant-search overlay. + +**Architecture:** Two private CPTs (`jp_search_behavior`, `jetpack_search_topic`) synced to wpcom via the Search sync module. An HMAC token embedded in `JetpackInstantSearchOptions` authenticates anonymous overlay visitors to the wpcom AI agent endpoint. The Search dashboard gains Stats-style top-level tabs (Overview / Behavior / Topics). The instant-search overlay gains an `AnswersPanel` component that streams tokens from the wpcom agent endpoint via `fetchEventSource`. + +**Tech Stack:** PHP 7.4+, PHPUnit, WordPress REST API (`wp_apiFetch`), React 18, `@wordpress/data`, `@microsoft/fetch-event-source` (new dep), Jest. + +**Scope note:** This plan covers the WP plugin side only. The wpcom AI agent (`Jetpack_Search_Answers_Agent`, topic-lookup ability, quota tracking) is a separate implementation documented in `2026-04-21-search-ai-answers-api-design.md`. The overlay panel will render correctly but the AI call will return 404 until the wpcom agent is live. + +--- + +## File Map + +### New files + +| File | Purpose | +|------|---------| +| `src/class-ai-answers.php` | CPT registration, postmeta registration, `jetpack_search_ai_answers_enabled` filter hook | +| `src/dashboard/components/tabs/index.jsx` | Stats-style tab bar component | +| `src/dashboard/components/tabs/index.scss` | Tab bar styles | +| `src/dashboard/components/behavior-tab/index.jsx` | Behavior tab — reads/writes `jp_search_behavior` post via REST | +| `src/dashboard/components/topics-tab/index.jsx` | Topics tab — lists `jetpack_search_topic` posts, links to WP post editor | +| `src/instant-search/components/answers-panel.jsx` | Overlay AI answers panel (idle / loading / streaming / done / error states) | +| `src/instant-search/components/answers-panel.scss` | Panel styles | +| `tests/php/AI_Answers_Test.php` | PHPUnit tests for CPT registration and HMAC token | + +### Modified files + +| File | Change | +|------|--------| +| `src/initializers/class-initializer.php` | Initialize `AI_Answers` in `init_before_connection()` | +| `src/class-helper.php` | Add `aiAnswersToken` and `siteId` to `generate_initial_javascript_state()` | +| `src/dashboard/components/pages/dashboard-page.jsx` | Wrap content in tab bar; move existing content to Overview tab | +| `src/instant-search/components/search-app.jsx` | Add `AnswersPanel` above `SearchResults`; wire SSE on query change | +| `package.json` | Add `@microsoft/fetch-event-source` dependency | +| `projects/packages/sync/src/modules/class-search.php` | Add CPT and postmeta sync hooks | +| `projects/packages/sync/tests/php/modules/Module_Test.php` | Sync module unit tests | + +--- + +## Task 1: Register CPTs + +**Files:** +- Create: `src/class-ai-answers.php` +- Modify: `src/initializers/class-initializer.php` +- Test: `tests/php/AI_Answers_Test.php` + +- [ ] **Step 1: Write failing test** + +```php +init(); + do_action( 'init' ); + } + + public function test_behavior_cpt_registered() { + $this->assertTrue( post_type_exists( 'jp_search_behavior' ) ); + } + + public function test_topic_cpt_registered() { + $this->assertTrue( post_type_exists( 'jetpack_search_topic' ) ); + } + + public function test_cpts_are_private() { + $behavior = get_post_type_object( 'jp_search_behavior' ); + $topic = get_post_type_object( 'jetpack_search_topic' ); + $this->assertFalse( $behavior->public ); + $this->assertFalse( $topic->public ); + } + + public function test_cpts_show_in_rest() { + $behavior = get_post_type_object( 'jp_search_behavior' ); + $topic = get_post_type_object( 'jetpack_search_topic' ); + $this->assertTrue( $behavior->show_in_rest ); + $this->assertTrue( $topic->show_in_rest ); + } + + public function test_topic_postmeta_registered() { + $registered = get_registered_meta_keys( 'post', 'jetpack_search_topic' ); + $this->assertArrayHasKey( '_jstopic_keywords', $registered ); + $this->assertArrayHasKey( '_jstopic_url', $registered ); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +jetpack test php packages/search -v --filter=AI_Answers_Test +``` + +Expected: FAIL — class `AI_Answers` not found. + +- [ ] **Step 3: Create `src/class-ai-answers.php`** + +```php + array( + 'name' => 'Search Behavior', + 'singular_name' => 'Search Behavior', + ), + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'jetpack-search-behavior', + 'supports' => array( 'editor' ), + 'capability_type' => 'post', + 'map_meta_cap' => true, + ) + ); + + register_post_type( + self::TOPIC_CPT, + array( + 'labels' => array( + 'name' => 'Search Topics', + 'singular_name' => 'Search Topic', + ), + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'rest_base' => 'jetpack-search-topics', + 'supports' => array( 'title', 'editor' ), + 'capability_type' => 'post', + 'map_meta_cap' => true, + ) + ); + + register_post_meta( + self::TOPIC_CPT, + '_jstopic_keywords', + array( + 'single' => true, + 'type' => 'string', + 'show_in_rest' => true, + 'auth_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); + + register_post_meta( + self::TOPIC_CPT, + '_jstopic_url', + array( + 'single' => true, + 'type' => 'string', + 'show_in_rest' => true, + 'auth_callback' => function () { + return current_user_can( 'edit_posts' ); + }, + ) + ); + } + + /** + * Return true if AI Answers feature is enabled. + * Reads the jetpack_search_ai_answers_enabled option; defaults to false. + * + * @param bool $enabled Current enabled state. + * @return bool + */ + public function is_feature_enabled( $enabled ) { + return $enabled || (bool) get_option( 'jetpack_search_ai_answers_enabled', false ); + } + + /** + * Whether AI Answers is enabled for the current site. + */ + public static function is_enabled() { + return (bool) apply_filters( 'jetpack_search_ai_answers_enabled', false ); + } +} +``` + +- [ ] **Step 4: Wire into `Initializer::init_before_connection()`** + +In `src/initializers/class-initializer.php`, find the `init_before_connection()` method and add: + +```php +protected static function init_before_connection() { + // Set up Search API endpoints. + add_action( 'rest_api_init', array( new REST_Controller(), 'register_rest_routes' ) ); + // The dashboard has to be initialized before connection. + ( new Dashboard() )->init_hooks(); + // Register AI Answers CPTs (always, so REST API works in block editor). + ( new AI_Answers() )->init(); +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +jetpack test php packages/search -v --filter=AI_Answers_Test +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/class-ai-answers.php src/initializers/class-initializer.php tests/php/AI_Answers_Test.php +git commit -m "Search: register jp_search_behavior and jetpack_search_topic CPTs" +``` + +--- + +## Task 2: Add CPTs to Sync Whitelist + +**Files:** +- Modify: `projects/packages/sync/src/modules/class-search.php` +- Test: `projects/packages/sync/tests/php/modules/Module_Test.php` + +- [ ] **Step 1: Read the existing Module_Test.php to understand test style** + +```bash +head -60 projects/packages/sync/tests/php/modules/Module_Test.php +``` + +- [ ] **Step 2: Write failing tests** + +Add to `projects/packages/sync/tests/php/modules/Module_Test.php` (or create a new `Search_Module_Test.php` in the same directory if the existing file tests a different module): + +```php +public function test_ai_cpts_not_in_whitelist_when_search_disabled() { + // Ensure filter returns false (default). + $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() ); + $this->assertNotContains( 'jp_search_behavior', $list ); + $this->assertNotContains( 'jetpack_search_topic', $list ); +} + +public function test_ai_cpts_in_whitelist_when_search_enabled() { + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $list = apply_filters( 'jetpack_sync_post_types_whitelist', array() ); + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $this->assertContains( 'jp_search_behavior', $list ); + $this->assertContains( 'jetpack_search_topic', $list ); +} + +public function test_ai_topic_meta_in_whitelist_when_search_enabled() { + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $list = apply_filters( 'jetpack_sync_post_meta_whitelist', array() ); + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + $this->assertContains( '_jstopic_keywords', $list ); + $this->assertContains( '_jstopic_url', $list ); +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +jetpack test php packages/sync -v --filter=test_ai_cpts +``` + +Expected: FAIL. + +- [ ] **Step 4: Add sync hooks to `class-search.php`** + +In `projects/packages/sync/src/modules/class-search.php`, in the `__construct()` method, add after the existing `add_filter` calls: + +```php +public function __construct() { + // existing hooks: + add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_search_post_meta_whitelist' ), 10 ); + add_filter( 'jetpack_sync_options_whitelist', array( $this, 'add_search_options_whitelist' ), 10 ); + // new hooks: + add_filter( 'jetpack_sync_post_types_whitelist', array( $this, 'add_ai_answer_post_types' ), 10 ); + add_filter( 'jetpack_sync_post_meta_whitelist', array( $this, 'add_ai_answer_post_meta' ), 10 ); +} +``` + +Then add the two new methods anywhere after `add_search_options_whitelist()`: + +```php +/** + * Add AI Answer CPTs to the post types sync whitelist. + * Only added when the jetpack_search_ai_answers_enabled filter returns true, + * which the Search package sets when Search is active. + * + * @param array $list Existing post types whitelist. + * @return array Updated whitelist. + */ +public function add_ai_answer_post_types( $list ) { + if ( ! apply_filters( 'jetpack_search_ai_answers_enabled', false ) ) { + return $list; + } + $list[] = 'jp_search_behavior'; + $list[] = 'jetpack_search_topic'; + return $list; +} + +/** + * Add AI topic postmeta keys to the sync whitelist. + * + * @param array $list Existing postmeta whitelist. + * @return array Updated whitelist. + */ +public function add_ai_answer_post_meta( $list ) { + if ( ! apply_filters( 'jetpack_search_ai_answers_enabled', false ) ) { + return $list; + } + $list[] = '_jstopic_keywords'; + $list[] = '_jstopic_url'; + return $list; +} +``` + +- [ ] **Step 5: Run tests** + +```bash +jetpack test php packages/sync -v --filter=test_ai_cpts +``` + +Expected: All 3 tests PASS. + +- [ ] **Step 6: Run the full sync test suite to check for regressions** + +```bash +jetpack test php packages/sync -v +``` + +Expected: All tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add projects/packages/sync/src/modules/class-search.php projects/packages/sync/tests/php/modules/Module_Test.php +git commit -m "Sync: add jp_search_behavior and jetpack_search_topic CPTs to Search sync module" +``` + +--- + +## Task 3: Embed HMAC Token in Overlay Options + +**Files:** +- Modify: `src/class-helper.php` +- Test: `tests/php/AI_Answers_Test.php` + +- [ ] **Step 1: Add tests to `AI_Answers_Test.php`** + +```php +public function test_hmac_token_not_generated_when_disabled() { + // Feature disabled by default. + $state = Helper::generate_initial_javascript_state(); + $this->assertArrayNotHasKey( 'aiAnswersToken', $state ); +} + +public function test_hmac_token_generated_when_enabled() { + add_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + + // Mock the blog token — Helper::get_wpcom_site_id() may return 0 in tests, + // so we focus on the key being present with a non-empty string value. + $state = Helper::generate_initial_javascript_state(); + remove_filter( 'jetpack_search_ai_answers_enabled', '__return_true' ); + + $this->assertArrayHasKey( 'aiAnswersToken', $state ); + $this->assertNotEmpty( $state['aiAnswersToken'] ); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +jetpack test php packages/search -v --filter=test_hmac_token +``` + +Expected: FAIL — `aiAnswersToken` key not present. + +- [ ] **Step 3: Add token generation to `class-helper.php`** + +In `src/class-helper.php`, find `generate_initial_javascript_state()`. Near the end of the method, before the closing `return $options;`, add: + +```php +// AI Answers: embed site-level hourly HMAC token for anonymous visitors. +if ( AI_Answers::is_enabled() ) { + $blog_token = \Automattic\Jetpack\Connection\Tokens::get_access_token( JETPACK_MASTER_USER ); + if ( $blog_token && ! empty( $blog_token->secret ) ) { + $site_id = \Jetpack_Options::get_option( 'id', 0 ); + $options['aiAnswersToken'] = hash_hmac( + 'sha256', + 'search-answers:' . $site_id . ':' . floor( time() / 3600 ), + $blog_token->secret + ); + $options['aiAnswersSiteId'] = (int) $site_id; + } +} +``` + +Also add the `use` import at the top of the class file if not already present (check if `Tokens` is already imported). + +- [ ] **Step 4: Run tests** + +```bash +jetpack test php packages/search -v --filter=AI_Answers_Test +``` + +Expected: All tests PASS (the HMAC token test will pass if a blog token mock exists; if not, the token may be empty but the key will still be present — adjust the test assertion as needed based on the test bootstrap's mock setup). + +- [ ] **Step 5: Commit** + +```bash +git add src/class-helper.php tests/php/AI_Answers_Test.php +git commit -m "Search: embed HMAC AI Answers token in JetpackInstantSearchOptions" +``` + +--- + +## Task 4: Dashboard Tab Bar + +**Files:** +- Create: `src/dashboard/components/tabs/index.jsx` +- Create: `src/dashboard/components/tabs/index.scss` +- Modify: `src/dashboard/components/pages/dashboard-page.jsx` + +Stats-style tab bar: plain text tabs, blue bottom-border underline on the active tab, horizontal rule below the row. + +- [ ] **Step 1: Create `src/dashboard/components/tabs/index.scss`** + +```scss +.jp-search-dashboard-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid #dcdcde; + margin-bottom: 24px; +} + +.jp-search-dashboard-tabs__tab { + background: none; + border: none; + border-bottom: 3px solid transparent; + color: #3c434a; + cursor: pointer; + font-size: 14px; + font-weight: 400; + margin-bottom: -1px; + padding: 12px 16px; + text-decoration: none; + + &:hover { + color: #1d2327; + } + + &--active { + border-bottom-color: #3858e9; + color: #1d2327; + font-weight: 600; + } +} +``` + +- [ ] **Step 2: Create `src/dashboard/components/tabs/index.jsx`** + +```jsx +import { __ } from '@wordpress/i18n'; +import './index.scss'; + +const TABS = [ + { id: 'overview', label: __( 'Overview', 'jetpack-search-pkg' ) }, + { id: 'behavior', label: __( 'Behavior', 'jetpack-search-pkg' ) }, + { id: 'topics', label: __( 'Topics', 'jetpack-search-pkg' ) }, +]; + +/** + * Stats-style tab bar for the Search dashboard. + * + * @param {object} props + * @param {string} props.activeTab - The currently active tab id. + * @param {Function} props.onTabChange - Called with the tab id when a tab is clicked. + */ +export default function DashboardTabs( { activeTab, onTabChange } ) { + return ( +
+ { TABS.map( tab => ( + + ) ) } +
+ ); +} + +export { TABS }; +``` + +- [ ] **Step 3: Write a Jest test for the tab component** + +Create `tests/js/dashboard/tabs.test.jsx`: + +```jsx +import { render, screen, fireEvent } from '@testing-library/react'; +import DashboardTabs from 'components/tabs'; + +describe( 'DashboardTabs', () => { + it( 'renders three tabs', () => { + render( ); + expect( screen.getByText( 'Overview' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Behavior' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Topics' ) ).toBeInTheDocument(); + } ); + + it( 'marks the active tab', () => { + render( ); + const active = screen.getByRole( 'tab', { name: 'Behavior' } ); + expect( active ).toHaveAttribute( 'aria-selected', 'true' ); + } ); + + it( 'calls onTabChange when a tab is clicked', () => { + const onChange = jest.fn(); + render( ); + fireEvent.click( screen.getByText( 'Topics' ) ); + expect( onChange ).toHaveBeenCalledWith( 'topics' ); + } ); +} ); +``` + +- [ ] **Step 4: Run JS tests** + +```bash +cd projects/packages/search && pnpm test-scripts -- --testPathPattern=tabs +``` + +Expected: All 3 tests PASS. + +- [ ] **Step 5: Integrate tabs into `dashboard-page.jsx`** + +Add imports and `useState` at the top of `DashboardPage`: + +```jsx +import { useState } from 'react'; +import DashboardTabs from 'components/tabs'; +import BehaviorTab from 'components/behavior-tab'; +import TopicsTab from 'components/topics-tab'; +``` + +Replace the `return (` block in `DashboardPage` with: + +```jsx +const [ activeTab, setActiveTab ] = useState( 'overview' ); + +return ( +
+ + + + { activeTab === 'overview' && ( + <> + { /* All existing dashboard content, unchanged */ } +
+ { /* ... */ } +
+ { /* ... rest of existing JSX */ } + + ) } + + { activeTab === 'behavior' && } + { activeTab === 'topics' && } +
+
+); +``` + +Move the entire existing JSX inside `DashboardPage` under `activeTab === 'overview'`. Do not change the existing JSX — just wrap it in the conditional. + +- [ ] **Step 6: Commit** + +```bash +git add src/dashboard/components/tabs/ src/dashboard/components/pages/dashboard-page.jsx +git commit -m "Search: add Stats-style tab bar to Search dashboard (Overview / Behavior / Topics)" +``` + +--- + +## Task 5: Behavior Tab + +**Files:** +- Create: `src/dashboard/components/behavior-tab/index.jsx` + +Reads and writes the single `jp_search_behavior` post via the WP REST API. No full Gutenberg editor — a plain `