From 98ac5aedd980b03f904d0e8e82e564f56c165748 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Fri, 12 Jun 2026 12:47:03 +0530 Subject: [PATCH 01/21] Add Suggest Reply feature with AI-generated reply suggestions for comments --- .../Suggest_Reply/Reply_Suggestion.php | 172 ++++++++++++++ .../Suggest_Reply/system-instruction.php | 17 ++ includes/Experiments/Experiments.php | 1 + .../Suggest_Reply/Suggest_Reply.php | 94 ++++++++ .../suggest-reply/components/ReplyModal.tsx | 216 ++++++++++++++++++ .../components/ReplyModalController.tsx | 186 +++++++++++++++ src/experiments/suggest-reply/index.tsx | 35 +++ webpack.config.js | 5 + 8 files changed, 726 insertions(+) create mode 100644 includes/Abilities/Suggest_Reply/Reply_Suggestion.php create mode 100644 includes/Abilities/Suggest_Reply/system-instruction.php create mode 100644 includes/Experiments/Suggest_Reply/Suggest_Reply.php create mode 100644 src/experiments/suggest-reply/components/ReplyModal.tsx create mode 100644 src/experiments/suggest-reply/components/ReplyModalController.tsx create mode 100644 src/experiments/suggest-reply/index.tsx diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php new file mode 100644 index 000000000..a8115806d --- /dev/null +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -0,0 +1,172 @@ + 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The ID of the comment to generate a reply for.', 'ai' ), + ), + 'tone' => array( + 'type' => 'string', + 'enum' => array( 'professional', 'friendly', 'casual' ), + 'default' => 'friendly', + 'description' => esc_html__( 'The tone for the reply.', 'ai' ), + ) + ), + 'required' => array( 'comment_id' ), + ); + } + + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The comment ID.', 'ai' ), + ), + 'reply' => array( + 'type' => 'string', + 'description' => esc_html__( 'The generated reply suggestion.', 'ai' ), + ), + ), + ); + } + + protected function execute_callback( $input ) { + $input = wp_parse_args( + (array) $input, + array( + 'comment_id' => 0, + 'tone' => 'friendly', + ) + ); + + $comment_id = absint( $input['comment_id'] ); + + if ( ! $comment_id ) { + return new WP_Error( + 'missing_comment_id', + esc_html__( 'A comment ID is required.', 'ai' ) + ); + } + + $comment = get_comment( $comment_id ); + + if ( ! $comment || ! is_a( $comment, '\WP_Comment' ) ) { + return new WP_Error( + 'comment_not_found', + sprintf( + /* translators: %d: Comment ID. */ + esc_html__( 'Comment with ID %d not found.', 'ai' ), + $comment_id + ) + ); + } + + // Fetch post context. + $post = get_post( $comment->comment_post_ID ); + $post_title = $post instanceof \WP_Post ? $post->post_title : ''; + $post_excerpt = $post instanceof \WP_Post + ? wp_trim_words( wp_strip_all_tags( $post->post_content ), 50 ) + : ''; + + $tone = in_array( $input['tone'], array( 'professional', 'friendly', 'casual' ), true ) + ? $input['tone'] + : 'friendly'; + + // Build the prompt context. + $context = $this->build_context( $comment, $post_title, $post_excerpt, $tone); + + // Generate the reply. + $reply = $this->generate_reply( $context ); + + if ( is_wp_error( $reply ) ) { + return $reply; + } + + return array( + 'comment_id' => $comment_id, + 'reply' => $reply, + ); + } + + protected function permission_callback( $input ) { + if ( ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate reply suggestions.', 'ai' ) + ); + } + + return true; + } + + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + private function build_context( + \WP_Comment $comment, + string $post_title, + string $post_excerpt, + string $tone + ): string { + $parts = array(); + + if ( '' !== $post_title ) { + $parts[] = sprintf( 'Post Title: %s', $post_title ); + } + + if ( '' !== $post_excerpt ) { + $parts[] = sprintf( 'Post Context: %s', $post_excerpt ); + } + + $parts[] = sprintf( 'Comment Author: %s', $comment->comment_author ); + $parts[] = sprintf( 'Comment: """%s"""', $comment->comment_content ); + $parts[] = sprintf( 'Requested Tone: %s', $tone ); + + return implode( "\n", $parts ); + } + + private function generate_reply( string $context ) { + $prompt_builder = wp_ai_client_prompt( $context ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ); + + $is_supported = $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Reply suggestion could not be generated. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + + if ( is_wp_error( $is_supported ) ) { + return $is_supported; + } + + $result = $prompt_builder->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return sanitize_textarea_field( trim( (string) $result ) ); + } +} diff --git a/includes/Abilities/Suggest_Reply/system-instruction.php b/includes/Abilities/Suggest_Reply/system-instruction.php new file mode 100644 index 000000000..d54fef970 --- /dev/null +++ b/includes/Abilities/Suggest_Reply/system-instruction.php @@ -0,0 +1,17 @@ + __( 'Suggest Reply', 'ai' ), + 'description' => __( 'Adds a "Suggest reply" action to the Comments screen. AI generates reply candidates based on the comment content, post context, and optional editorial guidelines, which the moderator can review, edit, and insert.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + ); + } + + public function register(): void { + // Register the reply-suggestion ability. + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + // Add "Suggest reply" row action to the Comments list table. + add_filter( 'comment_row_actions', array( $this, 'add_row_action' ), 10, 2 ); + + // Enqueue the JS bundle on the Comments screen. + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + public function register_abilities(): void { + wp_register_ability( + 'ai/reply-suggestion', + array( + 'label' => __( 'Reply Suggestion', 'ai' ), + 'description' => __( 'Generates AI-powered reply suggestions for a comment.', 'ai' ), + 'ability_class' => Reply_Suggestion_Ability::class, + ) + ); + } + + public function add_row_action( $actions, $comment ): array { + if ( + ! is_array( $actions ) || + ! $comment || + ! is_a( $comment, '\WP_Comment' ) + ) { + return $actions; + } + + // Only show for approved comments. + if ( '1' !== (string) $comment->comment_approved ) { + return $actions; + } + + $actions['wpai_suggest_reply'] = sprintf( + '%s', + absint( $comment->comment_ID ), + esc_attr__( 'Suggest a reply for this comment', 'ai' ), + esc_html__( 'Suggest reply', 'ai' ) + ); + + return $actions; + } + + public function enqueue_assets( string $hook_suffix ): void { + if ( 'edit-comments.php' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( + 'suggest_reply', + 'experiments/suggest-reply', + array( 'include_core_abilities' => true ) + ); + + Asset_Loader::localize_script( + 'suggest_reply', + 'SuggestReplyData', + array( + 'enabled' => $this->is_enabled(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } +} diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx new file mode 100644 index 000000000..6c5624c75 --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -0,0 +1,216 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + Modal, + Notice, + SelectControl, + Spinner, + TextareaControl, +} from '@wordpress/components'; +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; + +type Tone = 'professional' | 'friendly' | 'casual'; + +type ReplySuggestionResult = { + comment_id: number; + reply: string; +}; + +export type CachedReply = { + reply: string; + tone: Tone; +}; + +export type ReplyModalProps = { + commentId: number; + onClose: () => void; + onSelectReply: ( reply: string, commentId: number ) => void; + initialReply?: CachedReply | undefined; + onReplyChange?: ( ( data: CachedReply ) => void ) | undefined; +}; + +export function ReplyModal( { + commentId, + onClose, + onSelectReply, + initialReply, + onReplyChange, +}: ReplyModalProps ): React.ReactElement { + const [ isLoading, setIsLoading ] = useState( false ); + const [ reply, setReply ] = useState< string >( initialReply?.reply ?? '' ); + const [ tone, setTone ] = useState< Tone >( + initialReply?.tone ?? 'friendly' + ); + + const [ error, setError ] = useState< string | null >( null ); + const [ hasGenerated, setHasGenerated ] = useState( + !! initialReply?.reply + ); + + const generateReply = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const result = await runAbility< ReplySuggestionResult >( + 'ai/reply-suggestion', + { + comment_id: commentId, + tone, + } + ); + + const freshReply = result.reply ?? ''; + setReply( freshReply ); + setHasGenerated( true ); + onReplyChange?.( { reply: freshReply, tone } ); + } catch ( err ) { + setError( + err instanceof Error + ? err.message + : __( 'Failed to generate reply suggestion.', 'ai' ) + ); + } finally { + setIsLoading( false ); + } + }, [ commentId, tone, onReplyChange ] ); + + const handleSelect = useCallback( () => { + onSelectReply( reply, commentId ); + }, [ reply, commentId, onSelectReply ] ); + + const handleReplyChange = useCallback( + ( value: string ) => { + setReply( value ); + onReplyChange?.( { reply: value, tone } ); + }, + [ tone, onReplyChange ] + ); + + useEffect( () => { + if ( ! initialReply?.reply ) { + generateReply(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + const toneOptions = [ + { label: __( 'Friendly', 'ai' ), value: 'friendly' }, + { label: __( 'Professional', 'ai' ), value: 'professional' }, + { label: __( 'Casual', 'ai' ), value: 'casual' }, + ]; + + return ( + + { /* Controls row: tone selector + generate button */ } +
+ + + setTone( value as Tone ) } + __nextHasNoMarginBottom + /> + + +
+ + { /* Content area */ } +
+ { isLoading && ( + + + + { __( 'Generating reply suggestion…', 'ai' ) } + + + ) } + + { ! isLoading && error && ( + + + { error || + __( + 'An error occurred while generating the reply suggestion.', + 'ai' + ) } + + + + ) } + + { ! isLoading && ! error && reply && ( + + + + + + + + + + ) } + + { ! isLoading && ! error && ! reply && hasGenerated && ( + + { __( + 'The AI was unable to generate a reply suggestion for this comment.', + 'ai' + ) } + + ) } +
+
+ ); +} diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx new file mode 100644 index 000000000..628912682 --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -0,0 +1,186 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModal } from './ReplyModal'; +import type { CachedReply } from './ReplyModal'; + +type ModalState = { + isOpen: boolean; + commentId: number | null; +}; + +export function ReplyModalController(): React.ReactElement { + const [ modalState, setModalState ] = useState< ModalState >( { + isOpen: false, + commentId: null, + } ); + + const repliesCache = useRef< Map< number, CachedReply > >( new Map() ); + + const populateTimeoutRef = useRef< number | null >( null ); + + const openModal = useCallback( ( commentId: number ) => { + setModalState( { isOpen: true, commentId } ); + }, [] ); + + const closeModal = useCallback( () => { + setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); + }, [] ); + + const getCachedReply = useCallback( + ( commentId: number ): CachedReply | undefined => + repliesCache.current.get( commentId ), + [] + ); + + const setCachedReply = useCallback( + ( commentId: number, data: CachedReply ) => { + repliesCache.current.set( commentId, data ); + }, + [] + ); + + const populateReplyTextarea = useCallback( ( reply: string ) => { + const textarea = document.querySelector< HTMLTextAreaElement >( + '#replycontainer #replycontent' + ); + + if ( ! textarea ) { + return; + } + + textarea.value = reply; + textarea.focus(); + // Trigger any listeners bound to the input event (e.g. character counts). + textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); + }, [] ); + + const isInlineReplyOpenForComment = useCallback( + ( commentId: number ): boolean => { + const replyRow = + document.querySelector< HTMLElement >( '#replyrow' ); + const commentIdInput = document.querySelector< HTMLInputElement >( + '#replyrow #comment_ID' + ); + + if ( ! replyRow || ! commentIdInput ) { + return false; + } + + const isVisible = + replyRow.style.display !== 'none' && + replyRow.offsetParent !== null; + const isForComment = + parseInt( commentIdInput.value, 10 ) === commentId; + + return isVisible && isForComment; + }, + [] + ); + + const handleSelectReply = useCallback( + ( reply: string, commentId: number ) => { + closeModal(); + + if ( isInlineReplyOpenForComment( commentId ) ) { + populateReplyTextarea( reply ); + return; + } + + // Find and click WordPress's own Reply button to open the form. + const replyButton = document.querySelector< HTMLButtonElement >( + `#comment-${ commentId } .reply button` + ); + + if ( replyButton ) { + replyButton.click(); + } + + // Defer population to let WordPress render the inline reply row. + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + populateTimeoutRef.current = window.setTimeout( () => { + populateReplyTextarea( reply ); + populateTimeoutRef.current = null; + }, 150 ); + }, + [ closeModal, isInlineReplyOpenForComment, populateReplyTextarea ] + ); + + useEffect( () => { + const handleClick = ( event: MouseEvent ) => { + const target = event.target as HTMLElement; + + if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { + return; + } + + event.preventDefault(); + + const commentId = parseInt( + target.dataset[ 'commentId' ] ?? '0', + 10 + ); + + if ( commentId > 0 ) { + openModal( commentId ); + } + }; + + const commentList = document.querySelector( '#the-comment-list' ); + + if ( commentList ) { + commentList.addEventListener( + 'click', + handleClick as EventListener + ); + + return () => { + commentList.removeEventListener( + 'click', + handleClick as EventListener + ); + }; + } + + return undefined; + }, [ openModal ] ); + + // Clean up any pending timeout on unmount. + useEffect( () => { + return () => { + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + }; + }, [] ); + + return ( + <> + { modalState.isOpen && modalState.commentId !== null && ( + { + if ( modalState.commentId !== null ) { + setCachedReply( modalState.commentId, data ); + } + } } + /> + ) } + + ); +} diff --git a/src/experiments/suggest-reply/index.tsx b/src/experiments/suggest-reply/index.tsx new file mode 100644 index 000000000..1a1abc1b5 --- /dev/null +++ b/src/experiments/suggest-reply/index.tsx @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModalController } from './components/ReplyModalController'; + +declare global { + interface Window { + aiSuggestReplyData?: { + enabled: boolean; + nonce: string; + }; + } +} + +function init(): void { + const data = window.aiSuggestReplyData; + + if ( ! data?.enabled ) { + return; + } + + const container = document.createElement( 'div' ); + container.id = 'wpai-suggest-reply-root'; + document.body.appendChild( container ); + + createRoot( container ).render( ); +} + +domReady( init ); diff --git a/webpack.config.js b/webpack.config.js index 454133270..a31b1b8ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,6 +100,11 @@ module.exports = { 'src/experiments/comment-moderation', 'index.tsx' ), + 'experiments/suggest-reply': path.resolve( + process.cwd(), + 'src/experiments/suggest-reply', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation', From c27f5f8882a8347f16e57e75a277dc32b6a5290c Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Mon, 15 Jun 2026 13:37:08 +0530 Subject: [PATCH 02/21] feat: add optional editorial guidelines field to reply suggestion requests and update UI accordingly --- .../Suggest_Reply/Reply_Suggestion.php | 18 +- .../suggest-reply/components/ReplyModal.tsx | 246 ++++++++---------- .../components/ReplyModalController.tsx | 23 -- 3 files changed, 120 insertions(+), 167 deletions(-) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php index a8115806d..7a3af650f 100644 --- a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -27,7 +27,12 @@ protected function input_schema(): array { 'enum' => array( 'professional', 'friendly', 'casual' ), 'default' => 'friendly', 'description' => esc_html__( 'The tone for the reply.', 'ai' ), - ) + ), + 'guidelines' => array( + 'type' => 'string', + 'default' => '', + 'description' => esc_html__( 'Optional free-text editorial guidelines to apply when writing the reply.', 'ai' ), + ), ), 'required' => array( 'comment_id' ), ); @@ -55,6 +60,7 @@ protected function execute_callback( $input ) { array( 'comment_id' => 0, 'tone' => 'friendly', + 'guidelines' => '', ) ); @@ -90,9 +96,10 @@ protected function execute_callback( $input ) { $tone = in_array( $input['tone'], array( 'professional', 'friendly', 'casual' ), true ) ? $input['tone'] : 'friendly'; + $guidelines = sanitize_textarea_field( (string) $input['guidelines'] ); // Build the prompt context. - $context = $this->build_context( $comment, $post_title, $post_excerpt, $tone); + $context = $this->build_context( $comment, $post_title, $post_excerpt, $tone, $guidelines ); // Generate the reply. $reply = $this->generate_reply( $context ); @@ -128,7 +135,8 @@ private function build_context( \WP_Comment $comment, string $post_title, string $post_excerpt, - string $tone + string $tone, + string $guidelines = '' ): string { $parts = array(); @@ -144,6 +152,10 @@ private function build_context( $parts[] = sprintf( 'Comment: """%s"""', $comment->comment_content ); $parts[] = sprintf( 'Requested Tone: %s', $tone ); + if ( '' !== $guidelines ) { + $parts[] = sprintf( 'Editorial Guidelines: %s', $guidelines ); + } + return implode( "\n", $parts ); } diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 6c5624c75..cd0fee21e 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -16,7 +16,7 @@ import { Spinner, TextareaControl, } from '@wordpress/components'; -import { useState, useEffect, useCallback } from '@wordpress/element'; +import { useState, useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -31,186 +31,150 @@ type ReplySuggestionResult = { reply: string; }; -export type CachedReply = { - reply: string; - tone: Tone; -}; - export type ReplyModalProps = { commentId: number; onClose: () => void; - onSelectReply: ( reply: string, commentId: number ) => void; - initialReply?: CachedReply | undefined; - onReplyChange?: ( ( data: CachedReply ) => void ) | undefined; + onSelectReply: (reply: string, commentId: number) => void; }; -export function ReplyModal( { +export function ReplyModal({ commentId, onClose, onSelectReply, - initialReply, - onReplyChange, -}: ReplyModalProps ): React.ReactElement { - const [ isLoading, setIsLoading ] = useState( false ); - const [ reply, setReply ] = useState< string >( initialReply?.reply ?? '' ); - const [ tone, setTone ] = useState< Tone >( - initialReply?.tone ?? 'friendly' - ); - - const [ error, setError ] = useState< string | null >( null ); - const [ hasGenerated, setHasGenerated ] = useState( - !! initialReply?.reply - ); +}: ReplyModalProps): React.ReactElement { + const [isLoading, setIsLoading] = useState(false); + const [reply, setReply] = useState(''); + const [tone, setTone] = useState('friendly'); + const [guidelines, setGuidelines] = useState(''); + const [error, setError] = useState(null); - const generateReply = useCallback( async () => { - setIsLoading( true ); - setError( null ); + const generateReply = useCallback(async () => { + setIsLoading(true); + setError(null); try { - const result = await runAbility< ReplySuggestionResult >( + const result = await runAbility( 'ai/reply-suggestion', { comment_id: commentId, tone, + guidelines, } ); - const freshReply = result.reply ?? ''; - setReply( freshReply ); - setHasGenerated( true ); - onReplyChange?.( { reply: freshReply, tone } ); - } catch ( err ) { + setReply(result.reply ?? ''); + } catch (err) { setError( err instanceof Error ? err.message - : __( 'Failed to generate reply suggestion.', 'ai' ) + : __('Failed to generate reply suggestion.', 'ai') ); } finally { - setIsLoading( false ); + setIsLoading(false); } - }, [ commentId, tone, onReplyChange ] ); - - const handleSelect = useCallback( () => { - onSelectReply( reply, commentId ); - }, [ reply, commentId, onSelectReply ] ); - - const handleReplyChange = useCallback( - ( value: string ) => { - setReply( value ); - onReplyChange?.( { reply: value, tone } ); - }, - [ tone, onReplyChange ] - ); + }, [commentId, tone, guidelines]); - useEffect( () => { - if ( ! initialReply?.reply ) { - generateReply(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); + + const handleSelect = useCallback(() => { + onSelectReply(reply, commentId); + }, [reply, commentId, onSelectReply]); const toneOptions = [ - { label: __( 'Friendly', 'ai' ), value: 'friendly' }, - { label: __( 'Professional', 'ai' ), value: 'professional' }, - { label: __( 'Casual', 'ai' ), value: 'casual' }, + { label: __('Friendly', 'ai'), value: 'friendly' }, + { label: __('Professional', 'ai'), value: 'professional' }, + { label: __('Casual', 'ai'), value: 'casual' }, ]; return ( - { /* Controls row: tone selector + generate button */ } -
- - - setTone( value as Tone ) } - __nextHasNoMarginBottom - /> - - -
- - { /* Content area */ } -
- { isLoading && ( - + + + setTone(value as Tone)} + /> + + + { /* Guidelines */} + + + + + { /* Loading spinner */} + {isLoading && ( + - { __( 'Generating reply suggestion…', 'ai' ) } + {__('Generating reply suggestion…', 'ai')} - ) } - - { ! isLoading && error && ( - - - { error || - __( - 'An error occurred while generating the reply suggestion.', - 'ai' - ) } + )} + + { /* Error notice */} + {!isLoading && error && ( + + + {error} - - - ) } - - { ! isLoading && ! error && reply && ( - - - - - - - - - ) } + )} - { ! isLoading && ! error && ! reply && hasGenerated && ( - - { __( - 'The AI was unable to generate a reply suggestion for this comment.', - 'ai' - ) } - - ) } -
-
+ { /* Generated reply */} + {!isLoading && !error && reply && ( + +

+ {reply} +

+
+ )} + + { /* Action buttons */} + + {reply && ( + + )} + + + + ); } diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx index 628912682..f98176f6b 100644 --- a/src/experiments/suggest-reply/components/ReplyModalController.tsx +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -12,7 +12,6 @@ import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; * Internal dependencies */ import { ReplyModal } from './ReplyModal'; -import type { CachedReply } from './ReplyModal'; type ModalState = { isOpen: boolean; @@ -25,10 +24,7 @@ export function ReplyModalController(): React.ReactElement { commentId: null, } ); - const repliesCache = useRef< Map< number, CachedReply > >( new Map() ); - const populateTimeoutRef = useRef< number | null >( null ); - const openModal = useCallback( ( commentId: number ) => { setModalState( { isOpen: true, commentId } ); }, [] ); @@ -37,19 +33,6 @@ export function ReplyModalController(): React.ReactElement { setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); }, [] ); - const getCachedReply = useCallback( - ( commentId: number ): CachedReply | undefined => - repliesCache.current.get( commentId ), - [] - ); - - const setCachedReply = useCallback( - ( commentId: number, data: CachedReply ) => { - repliesCache.current.set( commentId, data ); - }, - [] - ); - const populateReplyTextarea = useCallback( ( reply: string ) => { const textarea = document.querySelector< HTMLTextAreaElement >( '#replycontainer #replycontent' @@ -173,12 +156,6 @@ export function ReplyModalController(): React.ReactElement { commentId={ modalState.commentId } onClose={ closeModal } onSelectReply={ handleSelectReply } - initialReply={ getCachedReply( modalState.commentId ) } - onReplyChange={ ( data ) => { - if ( modalState.commentId !== null ) { - setCachedReply( modalState.commentId, data ); - } - } } /> ) } From 5cbd7b14b1116f169d81641480605b31b753cf06 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Mon, 15 Jun 2026 13:48:34 +0530 Subject: [PATCH 03/21] refactor: remove unnecessary useCallback hooks in ReplyModalController to simplify component logic --- .../components/ReplyModalController.tsx | 167 ++++++------------ 1 file changed, 58 insertions(+), 109 deletions(-) diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx index f98176f6b..f37f856d0 100644 --- a/src/experiments/suggest-reply/components/ReplyModalController.tsx +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -6,7 +6,7 @@ import React from 'react'; /** * WordPress dependencies */ -import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; +import { useEffect, useState, useRef } from '@wordpress/element'; /** * Internal dependencies @@ -19,145 +19,94 @@ type ModalState = { }; export function ReplyModalController(): React.ReactElement { - const [ modalState, setModalState ] = useState< ModalState >( { + const [modalState, setModalState] = useState({ isOpen: false, commentId: null, - } ); + }); - const populateTimeoutRef = useRef< number | null >( null ); - const openModal = useCallback( ( commentId: number ) => { - setModalState( { isOpen: true, commentId } ); - }, [] ); + const populateTimeoutRef = useRef(null); - const closeModal = useCallback( () => { - setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); - }, [] ); - - const populateReplyTextarea = useCallback( ( reply: string ) => { - const textarea = document.querySelector< HTMLTextAreaElement >( + const populateReplyTextarea = (reply: string) => { + const textarea = document.querySelector( '#replycontainer #replycontent' ); - - if ( ! textarea ) { - return; - } - + if (!textarea) return; textarea.value = reply; textarea.focus(); - // Trigger any listeners bound to the input event (e.g. character counts). - textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); - }, [] ); - - const isInlineReplyOpenForComment = useCallback( - ( commentId: number ): boolean => { - const replyRow = - document.querySelector< HTMLElement >( '#replyrow' ); - const commentIdInput = document.querySelector< HTMLInputElement >( - '#replyrow #comment_ID' - ); - - if ( ! replyRow || ! commentIdInput ) { - return false; - } + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }; - const isVisible = - replyRow.style.display !== 'none' && - replyRow.offsetParent !== null; - const isForComment = - parseInt( commentIdInput.value, 10 ) === commentId; + const isInlineReplyOpenForComment = (commentId: number): boolean => { + const replyRow = document.querySelector('#replyrow'); + const commentIdInput = document.querySelector('#replyrow #comment_ID'); + if (!replyRow || !commentIdInput) return false; + const isVisible = replyRow.style.display !== 'none' && replyRow.offsetParent !== null; + const isForComment = parseInt(commentIdInput.value, 10) === commentId; + return isVisible && isForComment; + }; - return isVisible && isForComment; - }, - [] - ); - - const handleSelectReply = useCallback( - ( reply: string, commentId: number ) => { - closeModal(); - - if ( isInlineReplyOpenForComment( commentId ) ) { - populateReplyTextarea( reply ); - return; - } + const closeModal = () => setModalState((prev) => ({ ...prev, isOpen: false })); - // Find and click WordPress's own Reply button to open the form. - const replyButton = document.querySelector< HTMLButtonElement >( - `#comment-${ commentId } .reply button` - ); + const handleSelectReply = (reply: string, commentId: number) => { + closeModal(); - if ( replyButton ) { - replyButton.click(); - } + if (isInlineReplyOpenForComment(commentId)) { + populateReplyTextarea(reply); + return; + } - // Defer population to let WordPress render the inline reply row. - if ( populateTimeoutRef.current !== null ) { - window.clearTimeout( populateTimeoutRef.current ); - } - populateTimeoutRef.current = window.setTimeout( () => { - populateReplyTextarea( reply ); - populateTimeoutRef.current = null; - }, 150 ); - }, - [ closeModal, isInlineReplyOpenForComment, populateReplyTextarea ] - ); + const replyButton = document.querySelector( + `#comment-${commentId} .reply button` + ); + if (replyButton) replyButton.click(); - useEffect( () => { - const handleClick = ( event: MouseEvent ) => { + if (populateTimeoutRef.current !== null) { + window.clearTimeout(populateTimeoutRef.current); + } + populateTimeoutRef.current = window.setTimeout(() => { + populateReplyTextarea(reply); + populateTimeoutRef.current = null; + }, 150); + }; + + useEffect(() => { + const handleClick = (event: MouseEvent) => { const target = event.target as HTMLElement; - - if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { - return; - } - + if (!target.classList.contains('wpai-suggest-reply')) return; event.preventDefault(); - - const commentId = parseInt( - target.dataset[ 'commentId' ] ?? '0', - 10 - ); - - if ( commentId > 0 ) { - openModal( commentId ); + const commentId = parseInt(target.dataset['commentId'] ?? '0', 10); + if (commentId > 0) { + setModalState({ isOpen: true, commentId }); } }; - const commentList = document.querySelector( '#the-comment-list' ); - - if ( commentList ) { - commentList.addEventListener( - 'click', - handleClick as EventListener - ); - - return () => { - commentList.removeEventListener( - 'click', - handleClick as EventListener - ); - }; + const commentList = document.querySelector('#the-comment-list'); + + if (commentList) { + commentList.addEventListener('click', handleClick as EventListener); + return () => commentList.removeEventListener('click', handleClick as EventListener); } return undefined; - }, [ openModal ] ); + }, []); - // Clean up any pending timeout on unmount. - useEffect( () => { + useEffect(() => { return () => { - if ( populateTimeoutRef.current !== null ) { - window.clearTimeout( populateTimeoutRef.current ); + if (populateTimeoutRef.current !== null) { + window.clearTimeout(populateTimeoutRef.current); } }; - }, [] ); + }, []); return ( <> - { modalState.isOpen && modalState.commentId !== null && ( + {modalState.isOpen && modalState.commentId !== null && ( - ) } + )} ); } From 1f00b1d7f81aa95f398c785d05d0404f3bb96a81 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Mon, 15 Jun 2026 13:52:00 +0530 Subject: [PATCH 04/21] refactor: inline handleSelect callback in ReplyModal to simplify component logic --- src/experiments/suggest-reply/components/ReplyModal.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index cd0fee21e..b892ffb80 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -74,11 +74,6 @@ export function ReplyModal({ } }, [commentId, tone, guidelines]); - - const handleSelect = useCallback(() => { - onSelectReply(reply, commentId); - }, [reply, commentId, onSelectReply]); - const toneOptions = [ { label: __('Friendly', 'ai'), value: 'friendly' }, { label: __('Professional', 'ai'), value: 'professional' }, @@ -157,7 +152,7 @@ export function ReplyModal({ {reply && ( - )} + ) } - + ); } diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx index f37f856d0..ce22d24bc 100644 --- a/src/experiments/suggest-reply/components/ReplyModalController.tsx +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -19,94 +19,113 @@ type ModalState = { }; export function ReplyModalController(): React.ReactElement { - const [modalState, setModalState] = useState({ + const [ modalState, setModalState ] = useState< ModalState >( { isOpen: false, commentId: null, - }); + } ); - const populateTimeoutRef = useRef(null); + const populateTimeoutRef = useRef< number | null >( null ); - const populateReplyTextarea = (reply: string) => { - const textarea = document.querySelector( + const populateReplyTextarea = ( reply: string ) => { + const textarea = document.querySelector< HTMLTextAreaElement >( '#replycontainer #replycontent' ); - if (!textarea) return; + if ( ! textarea ) { + return; + } textarea.value = reply; textarea.focus(); - textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); }; - const isInlineReplyOpenForComment = (commentId: number): boolean => { - const replyRow = document.querySelector('#replyrow'); - const commentIdInput = document.querySelector('#replyrow #comment_ID'); - if (!replyRow || !commentIdInput) return false; - const isVisible = replyRow.style.display !== 'none' && replyRow.offsetParent !== null; - const isForComment = parseInt(commentIdInput.value, 10) === commentId; + const isInlineReplyOpenForComment = ( commentId: number ): boolean => { + const replyRow = document.querySelector< HTMLElement >( '#replyrow' ); + const commentIdInput = document.querySelector< HTMLInputElement >( + '#replyrow #comment_ID' + ); + if ( ! replyRow || ! commentIdInput ) { + return false; + } + const isVisible = + replyRow.style.display !== 'none' && replyRow.offsetParent !== null; + const isForComment = parseInt( commentIdInput.value, 10 ) === commentId; return isVisible && isForComment; }; - const closeModal = () => setModalState((prev) => ({ ...prev, isOpen: false })); + const closeModal = () => + setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); - const handleSelectReply = (reply: string, commentId: number) => { + const handleSelectReply = ( reply: string, commentId: number ) => { closeModal(); - if (isInlineReplyOpenForComment(commentId)) { - populateReplyTextarea(reply); + if ( isInlineReplyOpenForComment( commentId ) ) { + populateReplyTextarea( reply ); return; } - const replyButton = document.querySelector( - `#comment-${commentId} .reply button` + const replyButton = document.querySelector< HTMLButtonElement >( + `#comment-${ commentId } .reply button` ); - if (replyButton) replyButton.click(); + if ( replyButton ) { + replyButton.click(); + } - if (populateTimeoutRef.current !== null) { - window.clearTimeout(populateTimeoutRef.current); + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); } - populateTimeoutRef.current = window.setTimeout(() => { - populateReplyTextarea(reply); + populateTimeoutRef.current = window.setTimeout( () => { + populateReplyTextarea( reply ); populateTimeoutRef.current = null; - }, 150); + }, 150 ); }; - useEffect(() => { - const handleClick = (event: MouseEvent) => { + useEffect( () => { + const handleClick = ( event: MouseEvent ) => { const target = event.target as HTMLElement; - if (!target.classList.contains('wpai-suggest-reply')) return; + if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { + return; + } event.preventDefault(); - const commentId = parseInt(target.dataset['commentId'] ?? '0', 10); - if (commentId > 0) { - setModalState({ isOpen: true, commentId }); + const commentId = parseInt( target.dataset.commentId ?? '0', 10 ); + if ( commentId > 0 ) { + setModalState( { isOpen: true, commentId } ); } }; - const commentList = document.querySelector('#the-comment-list'); - - if (commentList) { - commentList.addEventListener('click', handleClick as EventListener); - return () => commentList.removeEventListener('click', handleClick as EventListener); + const commentList = document.querySelector( '#the-comment-list' ); + + if ( commentList ) { + commentList.addEventListener( + 'click', + handleClick as EventListener + ); + return () => + commentList.removeEventListener( + 'click', + handleClick as EventListener + ); } return undefined; - }, []); + }, [] ); - useEffect(() => { + useEffect( () => { return () => { - if (populateTimeoutRef.current !== null) { - window.clearTimeout(populateTimeoutRef.current); + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); } }; - }, []); + }, [] ); return ( <> - {modalState.isOpen && modalState.commentId !== null && ( + { modalState.isOpen && modalState.commentId !== null && ( - )} + ) } ); } From 032de1afeebba5974c310db1235cb3c6dc647d29 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Mon, 15 Jun 2026 14:57:52 +0530 Subject: [PATCH 07/21] refactor: improve code readability in ReplyModalController by adding vertical whitespace and updating data attribute retrieval --- .../components/ReplyModalController.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx index ce22d24bc..8f7b52cf2 100644 --- a/src/experiments/suggest-reply/components/ReplyModalController.tsx +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -30,9 +30,11 @@ export function ReplyModalController(): React.ReactElement { const textarea = document.querySelector< HTMLTextAreaElement >( '#replycontainer #replycontent' ); + if ( ! textarea ) { return; } + textarea.value = reply; textarea.focus(); textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); @@ -43,12 +45,15 @@ export function ReplyModalController(): React.ReactElement { const commentIdInput = document.querySelector< HTMLInputElement >( '#replyrow #comment_ID' ); + if ( ! replyRow || ! commentIdInput ) { return false; } + const isVisible = replyRow.style.display !== 'none' && replyRow.offsetParent !== null; const isForComment = parseInt( commentIdInput.value, 10 ) === commentId; + return isVisible && isForComment; }; @@ -66,6 +71,7 @@ export function ReplyModalController(): React.ReactElement { const replyButton = document.querySelector< HTMLButtonElement >( `#comment-${ commentId } .reply button` ); + if ( replyButton ) { replyButton.click(); } @@ -73,6 +79,7 @@ export function ReplyModalController(): React.ReactElement { if ( populateTimeoutRef.current !== null ) { window.clearTimeout( populateTimeoutRef.current ); } + populateTimeoutRef.current = window.setTimeout( () => { populateReplyTextarea( reply ); populateTimeoutRef.current = null; @@ -82,11 +89,17 @@ export function ReplyModalController(): React.ReactElement { useEffect( () => { const handleClick = ( event: MouseEvent ) => { const target = event.target as HTMLElement; + if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { return; } + event.preventDefault(); - const commentId = parseInt( target.dataset.commentId ?? '0', 10 ); + const commentId = parseInt( + target.getAttribute( 'data-comment-id' ) ?? '0', + 10 + ); + if ( commentId > 0 ) { setModalState( { isOpen: true, commentId } ); } @@ -99,6 +112,7 @@ export function ReplyModalController(): React.ReactElement { 'click', handleClick as EventListener ); + return () => commentList.removeEventListener( 'click', From bc26c9235f07e03e953bc4e0054cc7e55ce9d703 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Mon, 15 Jun 2026 16:15:00 +0530 Subject: [PATCH 08/21] fix: Improve error handling in ReplyModal and update styles to use CSS variables --- src/experiments/suggest-reply/components/ReplyModal.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 898df55ad..29de08c9d 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -63,9 +63,9 @@ export function ReplyModal( { ); setReply( result.reply ?? '' ); - } catch ( err ) { + } catch ( err: any ) { setError( - err instanceof Error + Boolean(err.message) ? err.message : __( 'Failed to generate reply suggestion.', 'ai' ) ); @@ -135,13 +135,10 @@ export function ReplyModal( {

{ reply } From cb18981e073f54f074c8f3a255505e0a8a6f67b9 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 11:51:15 +0530 Subject: [PATCH 09/21] docs: add missing PHPDoc blocks to Suggest Reply ability and experiment classes --- .../Suggest_Reply/Reply_Suggestion.php | 57 +++++++++++++++++++ .../Suggest_Reply/Suggest_Reply.php | 49 ++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php index 7a3af650f..b6cdb4a76 100644 --- a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -1,4 +1,9 @@ 'object', @@ -38,6 +53,11 @@ protected function input_schema(): array { ); } + /** + * {@inheritDoc} + * + * @since x.x.x + */ protected function output_schema(): array { return array( 'type' => 'object', @@ -54,6 +74,13 @@ protected function output_schema(): array { ); } + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @return array{comment_id: int, reply: string}|\WP_Error The result of the ability execution. + */ protected function execute_callback( $input ) { $input = wp_parse_args( (array) $input, @@ -114,6 +141,11 @@ protected function execute_callback( $input ) { ); } + /** + * {@inheritDoc} + * + * @since x.x.x + */ protected function permission_callback( $input ) { if ( ! current_user_can( 'moderate_comments' ) ) { return new WP_Error( @@ -125,12 +157,29 @@ protected function permission_callback( $input ) { return true; } + /** + * {@inheritDoc} + * + * @since x.x.x + */ protected function meta(): array { return array( 'show_in_rest' => true, ); } + /** + * Builds the prompt context string from the comment and post data. + * + * @since x.x.x + * + * @param \WP_Comment $comment The comment to reply to. + * @param string $post_title The title of the parent post. + * @param string $post_excerpt A short excerpt of the parent post content. + * @param string $tone The desired reply tone (e.g. 'friendly', 'professional'). + * @param string $guidelines Optional editorial guidelines to apply. + * @return string The assembled prompt context. + */ private function build_context( \WP_Comment $comment, string $post_title, @@ -159,6 +208,14 @@ private function build_context( return implode( "\n", $parts ); } + /** + * Generates a reply suggestion via the AI client. + * + * @since x.x.x + * + * @param string $context The assembled prompt context string. + * @return string|\WP_Error The sanitized reply text, or a WP_Error on failure. + */ private function generate_reply( string $context ) { $prompt_builder = wp_ai_client_prompt( $context ) ->using_system_instruction( $this->get_system_instruction() ) diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php index 99ddea96f..ed05ba5af 100644 --- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php +++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php @@ -1,4 +1,10 @@ __( 'Suggest Reply', 'ai' ), @@ -25,6 +48,11 @@ protected function load_metadata(): array { ); } + /** + * {@inheritDoc} + * + * @since x.x.x + */ public function register(): void { add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); @@ -33,6 +61,11 @@ public function register(): void { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } + /** + * Registers the reply suggestion ability. + * + * @since x.x.x + */ public function register_abilities(): void { wp_register_ability( 'ai/reply-suggestion', @@ -44,6 +77,15 @@ public function register_abilities(): void { ); } + /** + * Adds a "Suggest reply" action link to the comment row actions. + * + * @since x.x.x + * + * @param mixed $actions The existing comment row actions. + * @param \WP_Comment $comment The comment object. + * @return array The modified actions array. + */ public function add_row_action( $actions, $comment ): array { if ( ! is_array( $actions ) || @@ -63,6 +105,13 @@ public function add_row_action( $actions, $comment ): array { return $actions; } + /** + * Enqueues assets for the Suggest Reply experiment. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ public function enqueue_assets( string $hook_suffix ): void { if ( ! in_array( $hook_suffix, array( 'edit-comments.php', 'index.php' ), true ) ) { return; From 0b845e718cd863640dda4c206cc449e6b698a51c Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 12:00:20 +0530 Subject: [PATCH 10/21] docs: add PHPDoc and JSDoc documentation to suggest-reply experiment components and system instructions --- .../Abilities/Suggest_Reply/system-instruction.php | 12 ++++++++++++ .../suggest-reply/components/ReplyModal.tsx | 8 ++++++++ .../components/ReplyModalController.tsx | 14 ++++++++++++++ src/experiments/suggest-reply/index.tsx | 4 ++++ 4 files changed, 38 insertions(+) diff --git a/includes/Abilities/Suggest_Reply/system-instruction.php b/includes/Abilities/Suggest_Reply/system-instruction.php index d54fef970..89ae3e986 100644 --- a/includes/Abilities/Suggest_Reply/system-instruction.php +++ b/includes/Abilities/Suggest_Reply/system-instruction.php @@ -1,4 +1,16 @@ void; }; +/** + * Renders the AI reply suggestion modal, allowing the moderator to choose + * a tone, provide optional guidelines, generate a reply, and insert it. + */ export function ReplyModal( { commentId, onClose, diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx index 8f7b52cf2..43636cbeb 100644 --- a/src/experiments/suggest-reply/components/ReplyModalController.tsx +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -1,3 +1,7 @@ +/** + * Controller for the Suggest Reply modal. + */ + /** * External dependencies */ @@ -18,6 +22,10 @@ type ModalState = { commentId: number | null; }; +/** + * Mounts a delegated click listener on the comment list and renders the + * reply modal when a "Suggest reply" action link is clicked. + */ export function ReplyModalController(): React.ReactElement { const [ modalState, setModalState ] = useState< ModalState >( { isOpen: false, @@ -26,6 +34,7 @@ export function ReplyModalController(): React.ReactElement { const populateTimeoutRef = useRef< number | null >( null ); + /** Writes the generated reply into the inline reply textarea and focuses it. */ const populateReplyTextarea = ( reply: string ) => { const textarea = document.querySelector< HTMLTextAreaElement >( '#replycontainer #replycontent' @@ -40,6 +49,7 @@ export function ReplyModalController(): React.ReactElement { textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); }; + /** Returns true when the WordPress inline reply form is already open for the given comment. */ const isInlineReplyOpenForComment = ( commentId: number ): boolean => { const replyRow = document.querySelector< HTMLElement >( '#replyrow' ); const commentIdInput = document.querySelector< HTMLInputElement >( @@ -60,6 +70,10 @@ export function ReplyModalController(): React.ReactElement { const closeModal = () => setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); + /** + * Closes the modal and inserts the selected reply into the comment reply form. + * Opens the inline reply row first if it is not already visible. + */ const handleSelectReply = ( reply: string, commentId: number ) => { closeModal(); diff --git a/src/experiments/suggest-reply/index.tsx b/src/experiments/suggest-reply/index.tsx index 1a1abc1b5..6516e7fe8 100644 --- a/src/experiments/suggest-reply/index.tsx +++ b/src/experiments/suggest-reply/index.tsx @@ -1,3 +1,7 @@ +/** + * Suggest reply experiment plugin registration. + */ + /** * WordPress dependencies */ From 7000fb31ca01288047d3a36af892aa54d74613d7 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 12:02:53 +0530 Subject: [PATCH 11/21] style: fix whitespace and formatting in docblock comments and update package-lock.json --- .../Abilities/Suggest_Reply/Reply_Suggestion.php | 12 ++++++------ includes/Experiments/Suggest_Reply/Suggest_Reply.php | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php index b6cdb4a76..c20178a96 100644 --- a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -26,7 +26,7 @@ class Reply_Suggestion extends Abstract_Ability { /** * {@inheritDoc} - * + * * @since x.x.x */ protected function input_schema(): array { @@ -55,7 +55,7 @@ protected function input_schema(): array { /** * {@inheritDoc} - * + * * @since x.x.x */ protected function output_schema(): array { @@ -76,9 +76,9 @@ protected function output_schema(): array { /** * {@inheritDoc} - * + * * @since x.x.x - * + * * @return array{comment_id: int, reply: string}|\WP_Error The result of the ability execution. */ protected function execute_callback( $input ) { @@ -143,7 +143,7 @@ protected function execute_callback( $input ) { /** * {@inheritDoc} - * + * * @since x.x.x */ protected function permission_callback( $input ) { @@ -159,7 +159,7 @@ protected function permission_callback( $input ) { /** * {@inheritDoc} - * + * * @since x.x.x */ protected function meta(): array { diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php index ed05ba5af..e52bbea95 100644 --- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php +++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php @@ -28,7 +28,7 @@ class Suggest_Reply extends Abstract_Feature { /** * {@inheritDoc} - * + * * @since x.x.x */ public static function get_id(): string { @@ -37,7 +37,7 @@ public static function get_id(): string { /** * {@inheritDoc} - * + * * @since x.x.x */ protected function load_metadata(): array { @@ -50,7 +50,7 @@ protected function load_metadata(): array { /** * {@inheritDoc} - * + * * @since x.x.x */ public function register(): void { From bbc1d3ba12b0efdcd11c1aef29e3ad4db2c95390 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 14:08:42 +0530 Subject: [PATCH 12/21] feat: add clipboard copy functionality and implement focus management for reply generation actions --- .../suggest-reply/components/ReplyModal.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index f6eb3b0ca..fef9f93c7 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -20,13 +20,14 @@ import { Spinner, TextareaControl, } from '@wordpress/components'; -import { useState, useCallback } from '@wordpress/element'; +import { useState, useCallback, useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import { runAbility } from '../../../utils/run-ability'; +import { useCopyToClipboardFeedback } from '../../../hooks/use-copy-to-clipboard-feedback'; type Tone = 'professional' | 'friendly' | 'casual'; @@ -55,6 +56,15 @@ export function ReplyModal( { const [ tone, setTone ] = useState< Tone >( 'friendly' ); const [ guidelines, setGuidelines ] = useState< string >( '' ); const [ error, setError ] = useState< string | null >( null ); + + // Refs for focus management. + const useThisReplyRef = useRef< HTMLButtonElement >( null ); + const generateRegenerateRef = useRef< HTMLButtonElement >( null ); + + const { ref: copyRef, hasCopied } = useCopyToClipboardFeedback< HTMLButtonElement >( { + text: reply, + announcement: __( 'Reply copied to clipboard.', 'ai' ), + } ); const generateReply = useCallback( async () => { setIsLoading( true ); @@ -71,12 +81,18 @@ export function ReplyModal( { ); setReply( result.reply ?? '' ); + + // Defer focus so the button has time to mount after the reply renders. + setTimeout( () => useThisReplyRef.current?.focus(), 0 ); } catch ( err: any ) { setError( - Boolean(err.message) + Boolean( err.message ) ? err.message : __( 'Failed to generate reply suggestion.', 'ai' ) ); + + // Defer focus so the button has time to mount after the error notice renders. + setTimeout( () => generateRegenerateRef.current?.focus(), 0 ); } finally { setIsLoading( false ); } @@ -110,7 +126,7 @@ export function ReplyModal( { { reply && ( ) } + ) } From 6418f19cefe25a09a135d1d5614432739b847184 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 14:16:11 +0530 Subject: [PATCH 13/21] fix: cast comment post ID to integer and update docblock for row actions --- includes/Abilities/Suggest_Reply/Reply_Suggestion.php | 2 +- includes/Experiments/Suggest_Reply/Suggest_Reply.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php index c20178a96..f19e97371 100644 --- a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -114,7 +114,7 @@ protected function execute_callback( $input ) { } // Fetch post context. - $post = get_post( $comment->comment_post_ID ); + $post = get_post( (int) $comment->comment_post_ID ); $post_title = $post instanceof \WP_Post ? $post->post_title : ''; $post_excerpt = $post instanceof \WP_Post ? wp_trim_words( wp_strip_all_tags( $post->post_content ), 50 ) diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php index e52bbea95..797bd62e2 100644 --- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php +++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php @@ -82,9 +82,8 @@ public function register_abilities(): void { * * @since x.x.x * - * @param mixed $actions The existing comment row actions. + * @param mixed $actions The existing comment row actions. * @param \WP_Comment $comment The comment object. - * @return array The modified actions array. */ public function add_row_action( $actions, $comment ): array { if ( From b015cb78e5c59323a8c22b62a29cc8195f3c0416 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 14:44:16 +0530 Subject: [PATCH 14/21] refactor: improve docblock type hinting and format Suggest Reply UI components --- .../Experiments/Suggest_Reply/Suggest_Reply.php | 1 + .../suggest-reply/components/ReplyModal.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php index 797bd62e2..418eacb10 100644 --- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php +++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php @@ -84,6 +84,7 @@ public function register_abilities(): void { * * @param mixed $actions The existing comment row actions. * @param \WP_Comment $comment The comment object. + * @return array The modified actions array. */ public function add_row_action( $actions, $comment ): array { if ( diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index fef9f93c7..6a0c100c5 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -56,15 +56,16 @@ export function ReplyModal( { const [ tone, setTone ] = useState< Tone >( 'friendly' ); const [ guidelines, setGuidelines ] = useState< string >( '' ); const [ error, setError ] = useState< string | null >( null ); - + // Refs for focus management. const useThisReplyRef = useRef< HTMLButtonElement >( null ); const generateRegenerateRef = useRef< HTMLButtonElement >( null ); - const { ref: copyRef, hasCopied } = useCopyToClipboardFeedback< HTMLButtonElement >( { - text: reply, - announcement: __( 'Reply copied to clipboard.', 'ai' ), - } ); + const { ref: copyRef, hasCopied } = + useCopyToClipboardFeedback< HTMLButtonElement >( { + text: reply, + announcement: __( 'Reply copied to clipboard.', 'ai' ), + } ); const generateReply = useCallback( async () => { setIsLoading( true ); @@ -159,7 +160,8 @@ export function ReplyModal( {

Date: Tue, 16 Jun 2026 14:50:39 +0530 Subject: [PATCH 15/21] fix: prevent potential memory leaks by clearing focus timeouts in ReplyModal on unmount --- .../suggest-reply/components/ReplyModal.tsx | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 6a0c100c5..77475db08 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -20,7 +20,7 @@ import { Spinner, TextareaControl, } from '@wordpress/components'; -import { useState, useCallback, useRef } from '@wordpress/element'; +import { useState, useCallback, useRef, useEffect } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; /** @@ -60,6 +60,16 @@ export function ReplyModal( { // Refs for focus management. const useThisReplyRef = useRef< HTMLButtonElement >( null ); const generateRegenerateRef = useRef< HTMLButtonElement >( null ); + const focusTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + // Cancel any pending focus timeout when the modal unmounts. + useEffect( () => { + return () => { + if ( focusTimeoutRef.current !== null ) { + clearTimeout( focusTimeoutRef.current ); + } + }; + }, [] ); const { ref: copyRef, hasCopied } = useCopyToClipboardFeedback< HTMLButtonElement >( { @@ -84,7 +94,10 @@ export function ReplyModal( { setReply( result.reply ?? '' ); // Defer focus so the button has time to mount after the reply renders. - setTimeout( () => useThisReplyRef.current?.focus(), 0 ); + focusTimeoutRef.current = setTimeout( + () => useThisReplyRef.current?.focus(), + 0 + ); } catch ( err: any ) { setError( Boolean( err.message ) @@ -93,7 +106,10 @@ export function ReplyModal( { ); // Defer focus so the button has time to mount after the error notice renders. - setTimeout( () => generateRegenerateRef.current?.focus(), 0 ); + focusTimeoutRef.current = setTimeout( + () => generateRegenerateRef.current?.focus(), + 0 + ); } finally { setIsLoading( false ); } From 02471abb9d04b1e6186fd4354a4ea0b08692316e Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 15:00:08 +0530 Subject: [PATCH 16/21] feat: implement suggest reply functionality and add project documentation --- includes/Experiments/Suggest_Reply/README.md | 32 +++++++++++++++++++ .../suggest-reply/components/ReplyModal.tsx | 4 ++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 includes/Experiments/Suggest_Reply/README.md diff --git a/includes/Experiments/Suggest_Reply/README.md b/includes/Experiments/Suggest_Reply/README.md new file mode 100644 index 000000000..b65e780bd --- /dev/null +++ b/includes/Experiments/Suggest_Reply/README.md @@ -0,0 +1,32 @@ +# Suggest Reply + +Adds a "Suggest reply" action to the Comments screen and the Activity dashboard widget. AI generates reply candidates based on the comment content, post context, and optional editorial guidelines, which the moderator can review, edit, and insert. + +## Summary + +- Extends `Abstract_Feature` and registers the `ai/reply-suggestion` WP Ability. +- Injects a "Suggest reply" link into each comment row on the **Comments** admin screen and the **Activity** dashboard widget. +- Opens a modal that lets the moderator choose a reply tone, add editorial guidelines, and generate an AI reply. +- The generated reply can be inserted directly into the inline reply form, or copied to the clipboard. + +## Functionality + +- **Tone selection** — choose between *Friendly*, *Professional*, and *Casual*. +- **Optional guidelines** — free-text instructions the AI applies when drafting the reply (e.g. "always mention our support email"). +- **Generate / Regenerate** — calls the `ai/reply-suggestion` ability, which uses the comment content and parent post context as prompt input. +- **Use this reply** — inserts the generated reply into the WordPress inline comment reply form and focuses it. +- **Copy** — copies the reply to the clipboard with a transient "Copied!" confirmation. + +## Requirements + +- An AI provider must be connected and enabled in **Settings → AI**. +- The connected provider must support text generation. +- The current user must have the `moderate_comments` capability. + +## Usage + +1. Go to **Comments** or the **Dashboard** in the WordPress admin. +2. Hover over any comment row (or recent comment in the Activity widget) and click **Suggest reply**. +3. Optionally adjust the tone or add editorial guidelines. +4. Click **Generate** to produce an AI reply suggestion. +5. Click **Use this reply** to populate the inline reply form, or **Copy** to copy it to the clipboard. diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 77475db08..30467c7d0 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -60,7 +60,9 @@ export function ReplyModal( { // Refs for focus management. const useThisReplyRef = useRef< HTMLButtonElement >( null ); const generateRegenerateRef = useRef< HTMLButtonElement >( null ); - const focusTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + const focusTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( + null + ); // Cancel any pending focus timeout when the modal unmounts. useEffect( () => { From ef184713a234d65b8dae0fc03c6892df41270531 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 16 Jun 2026 15:58:45 +0530 Subject: [PATCH 17/21] feat: update experiment description and implement dynamic generate button labels and states in ReplyModal --- .../Suggest_Reply/Suggest_Reply.php | 2 +- .../suggest-reply/components/ReplyModal.tsx | 24 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/includes/Experiments/Suggest_Reply/Suggest_Reply.php b/includes/Experiments/Suggest_Reply/Suggest_Reply.php index 418eacb10..12321ec00 100644 --- a/includes/Experiments/Suggest_Reply/Suggest_Reply.php +++ b/includes/Experiments/Suggest_Reply/Suggest_Reply.php @@ -43,7 +43,7 @@ public static function get_id(): string { protected function load_metadata(): array { return array( 'label' => __( 'Suggest Reply', 'ai' ), - 'description' => __( 'Adds a "Suggest reply" action to the Comments screen. AI generates reply candidates based on the comment content, post context, and optional editorial guidelines, which the moderator can review, edit, and insert.', 'ai' ), + 'description' => __( 'Adds a "Suggest Reply" action to the Comments screen and Activity widget, enabling moderators to generate and insert AI-powered reply suggestions.', 'ai' ), 'category' => Experiment_Category::ADMIN, ); } diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 30467c7d0..6b9719b71 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -123,6 +123,22 @@ export function ReplyModal( { { label: __( 'Casual', 'ai' ), value: 'casual' }, ]; + const getGenerateText = useCallback( () => { + if ( isLoading ) { + return __( 'Generating…', 'ai' ); + } + + if ( error ) { + return __( 'Retry', 'ai' ); + } + + if ( reply ) { + return __( 'Regenerate', 'ai' ); + } + + return __( 'Generate', 'ai' ); + }, [ error, reply, isLoading ] ); + return ( - { reply && ( + { reply && ! error && ( - { reply && ( + { reply && ! error && ( - ) } { reply && ! error && ( - + <> + + + + ) } From 53e9dd1862f36c8d984847f6f9aadd5639ca4d85 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Thu, 18 Jun 2026 17:19:30 +0530 Subject: [PATCH 19/21] style: update ReplyModal components to use 40px default size for consistency --- src/experiments/suggest-reply/components/ReplyModal.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx index 5529c0c92..1421c1089 100644 --- a/src/experiments/suggest-reply/components/ReplyModal.tsx +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -125,6 +125,7 @@ export function ReplyModal( { value={ tone } options={ toneOptions } onChange={ ( value ) => setTone( value as Tone ) } + __next40pxDefaultSize /> @@ -186,6 +187,7 @@ export function ReplyModal( { disabled={ isLoading } isBusy={ isLoading } accessibleWhenDisabled + __next40pxDefaultSize > { getGenerateText() } @@ -197,6 +199,7 @@ export function ReplyModal( { onSelectReply( reply, commentId ) } disabled={ isLoading } + __next40pxDefaultSize > { __( 'Use this reply', 'ai' ) } @@ -205,6 +208,7 @@ export function ReplyModal( { ref={ copyRef } variant="tertiary" disabled={ isLoading } + __next40pxDefaultSize > { hasCopied ? __( 'Copied!', 'ai' ) From f0cf86f4fa1117b265802c21b53f285ca9496c04 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 23 Jun 2026 14:06:12 +0530 Subject: [PATCH 20/21] feat: document Suggest Reply experiment and update project dependencies --- docs/experiments/suggest-reply.md | 150 ++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/experiments/suggest-reply.md diff --git a/docs/experiments/suggest-reply.md b/docs/experiments/suggest-reply.md new file mode 100644 index 000000000..acb96a38d --- /dev/null +++ b/docs/experiments/suggest-reply.md @@ -0,0 +1,150 @@ +# Suggest Reply + +## Summary + +The Suggest Reply experiment adds an AI-powered "Suggest reply" action to the classic Comments admin screen and the Activity widget on the Dashboard. When activated, moderators can generate AI-suggested replies to comments, customize the tone, and provide specific guidelines for the reply. The experiment exposes one WordPress Ability (`ai/reply-suggestion`) that can be used from the UI or via REST API. + +## Overview + +When enabled, each comment in the Comments list table and the Dashboard Activity widget gets an additional **Suggest reply** action link. Clicking it opens a modal overlay allowing users to generate context-aware replies. + +**Key Features:** + +- Adds a "Suggest reply" action to comments in the list table and the Dashboard Activity widget +- Provides a modal interface to set the desired Tone (friendly, professional, casual) and optional editorial Guidelines +- Generates a single, relevant reply based on the comment text and parent post context +- Automatically populates the inline WordPress reply form when the generated reply is selected +- Uses one shared ability (`ai/reply-suggestion`) exposed via REST API + +### Input Schema + +The `ai/reply-suggestion` ability accepts: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => 'The ID of the comment to generate a reply for.', + 'required' => true, + ), + 'tone' => array( + 'type' => 'string', + 'enum' => array( 'professional', 'friendly', 'casual' ), + 'default' => 'friendly', + 'description' => 'The tone for the reply.', + ), + 'guidelines' => array( + 'type' => 'string', + 'default' => '', + 'description' => 'Optional free-text editorial guidelines to apply when writing the reply.', + ), + ), + 'required' => array( 'comment_id' ), +) +``` + +### Output Schema + +The ability returns: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => 'The comment ID.', + ), + 'reply' => array( + 'type' => 'string', + 'description' => 'The generated reply suggestion.', + ), + ), +) +``` + +### Permissions + +- `ai/reply-suggestion` requires `current_user_can( 'moderate_comments' )` + +## Using the Ability via REST API + +### Endpoint + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run +``` + +### Authentication + +You can authenticate using either: + +1. **Application Password** (Recommended) +2. **Cookie Authentication with Nonce** + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions. + +### Request Example + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "comment_id": 1, + "tone": "professional", + "guidelines": "Thank the user for their feedback." + } + }' +``` + +**Response:** + +```json +{ + "comment_id": 1, + "reply": "Thank you for your valuable feedback! We appreciate you taking the time to share your thoughts." +} +``` + +### Error Responses + +The ability may return: + +- `missing_comment_id`: `comment_id` was not provided +- `comment_not_found`: no comment exists for the given ID +- `insufficient_capabilities`: current user lacks moderation permissions + +## Testing + +### Manual Testing + +1. **Enable the experiment:** + - Go to `Settings -> AI` + - Enable global AI features and toggle **Suggest Reply** + - Ensure valid AI connector credentials are configured + +2. **Suggest reply modal:** + - Go to `Comments -> All Comments` + - Hover over an comment and click **Suggest reply** + - Select a Tone, enter Guidelines, and click **Generate** + - Verify that the AI generates a reply + - Click **Use this reply** and verify the inline comment reply textarea is populated with the text + +3. **REST API:** + - Call `POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run` with a valid `comment_id` + - Verify response shape and error handling for invalid IDs or insufficient permissions + +## Notes & Considerations + +### Requirements + +- Requires valid AI credentials and text-generation-capable models +- Requires users with comment moderation capabilities for ability access + +### Limitations + +- Works on the classic comments list table and the Dashboard Activity widget (no block-based comments UI integration here) From 02e6d3aa4c1349ddf0f05440e714bef7cf3d0f55 Mon Sep 17 00:00:00 2001 From: Infinite-Null Date: Tue, 23 Jun 2026 14:08:19 +0530 Subject: [PATCH 21/21] chore: remove suggest reply experiment documentation from `/Experiments/Suggest_Reply/` --- includes/Experiments/Suggest_Reply/README.md | 32 -------------------- 1 file changed, 32 deletions(-) delete mode 100644 includes/Experiments/Suggest_Reply/README.md diff --git a/includes/Experiments/Suggest_Reply/README.md b/includes/Experiments/Suggest_Reply/README.md deleted file mode 100644 index b65e780bd..000000000 --- a/includes/Experiments/Suggest_Reply/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Suggest Reply - -Adds a "Suggest reply" action to the Comments screen and the Activity dashboard widget. AI generates reply candidates based on the comment content, post context, and optional editorial guidelines, which the moderator can review, edit, and insert. - -## Summary - -- Extends `Abstract_Feature` and registers the `ai/reply-suggestion` WP Ability. -- Injects a "Suggest reply" link into each comment row on the **Comments** admin screen and the **Activity** dashboard widget. -- Opens a modal that lets the moderator choose a reply tone, add editorial guidelines, and generate an AI reply. -- The generated reply can be inserted directly into the inline reply form, or copied to the clipboard. - -## Functionality - -- **Tone selection** — choose between *Friendly*, *Professional*, and *Casual*. -- **Optional guidelines** — free-text instructions the AI applies when drafting the reply (e.g. "always mention our support email"). -- **Generate / Regenerate** — calls the `ai/reply-suggestion` ability, which uses the comment content and parent post context as prompt input. -- **Use this reply** — inserts the generated reply into the WordPress inline comment reply form and focuses it. -- **Copy** — copies the reply to the clipboard with a transient "Copied!" confirmation. - -## Requirements - -- An AI provider must be connected and enabled in **Settings → AI**. -- The connected provider must support text generation. -- The current user must have the `moderate_comments` capability. - -## Usage - -1. Go to **Comments** or the **Dashboard** in the WordPress admin. -2. Hover over any comment row (or recent comment in the Activity widget) and click **Suggest reply**. -3. Optionally adjust the tone or add editorial guidelines. -4. Click **Generate** to produce an AI reply suggestion. -5. Click **Use this reply** to populate the inline reply form, or **Copy** to copy it to the clipboard.