diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0f3cdb54bb358..516aec549b80d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -162,6 +162,9 @@ importers:
'@storybook/blocks':
specifier: 8.0.6
version: 8.0.6(@types/react@18.2.74)(react-dom@18.2.0)(react@18.2.0)
+ '@storybook/preview-api':
+ specifier: 8.0.6
+ version: 8.0.6
'@storybook/react':
specifier: 8.0.6
version: 8.0.6(react-dom@18.2.0)(react@18.2.0)(typescript@5.0.4)
diff --git a/projects/js-packages/ai-client/changelog/update-jetpack-ai-input-ui b/projects/js-packages/ai-client/changelog/update-jetpack-ai-input-ui
new file mode 100644
index 0000000000000..e4e0ade17621f
--- /dev/null
+++ b/projects/js-packages/ai-client/changelog/update-jetpack-ai-input-ui
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+AI Client: Separate AIControl UI from block logic
diff --git a/projects/js-packages/ai-client/package.json b/projects/js-packages/ai-client/package.json
index 5b2f30d96574c..6f6f2a23af325 100644
--- a/projects/js-packages/ai-client/package.json
+++ b/projects/js-packages/ai-client/package.json
@@ -25,6 +25,7 @@
"devDependencies": {
"@storybook/addon-actions": "8.0.6",
"@storybook/blocks": "8.0.6",
+ "@storybook/preview-api": "8.0.6",
"@storybook/react": "8.0.6",
"@types/markdown-it": "14.0.0",
"@types/turndown": "5.0.4",
diff --git a/projects/js-packages/ai-client/src/components/ai-control/ai-control.tsx b/projects/js-packages/ai-client/src/components/ai-control/ai-control.tsx
new file mode 100644
index 0000000000000..43f660420aa04
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/ai-control.tsx
@@ -0,0 +1,79 @@
+/**
+ * External dependencies
+ */
+import { PlainText } from '@wordpress/block-editor';
+import classNames from 'classnames';
+import React from 'react';
+/**
+ * Internal dependencies
+ */
+import AiStatusIndicator from '../ai-status-indicator/index.js';
+import './style.scss';
+/**
+ * Types
+ */
+import type { RequestingStateProp } from '../../types.js';
+import type { ReactElement } from 'react';
+
+type AIControlProps = {
+ disabled?: boolean;
+ value: string;
+ placeholder?: string;
+ isTransparent?: boolean;
+ state?: RequestingStateProp;
+ onChange?: ( newValue: string ) => void;
+ banner?: ReactElement;
+ error?: ReactElement;
+ actions?: ReactElement;
+ message?: ReactElement;
+ promptUserInputRef?: React.MutableRefObject< HTMLInputElement >;
+};
+
+/**
+ * Base AIControl component. Contains the main structure of the control component and slots for banner, error, actions and message.
+ *
+ * @param {AIControlProps} props - Component props
+ * @returns {ReactElement} Rendered component
+ */
+export default function AIControl( {
+ disabled = false,
+ value = '',
+ placeholder = '',
+ isTransparent = false,
+ state = 'init',
+ onChange,
+ banner = null,
+ error = null,
+ actions = null,
+ message = null,
+ promptUserInputRef = null,
+}: AIControlProps ): ReactElement {
+ return (
+
+ { error }
+
+ { banner }
+
+ { message }
+
+
+ );
+}
diff --git a/projects/js-packages/ai-client/src/components/ai-control/block-ai-control.tsx b/projects/js-packages/ai-client/src/components/ai-control/block-ai-control.tsx
new file mode 100644
index 0000000000000..c6d16298e033d
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/block-ai-control.tsx
@@ -0,0 +1,278 @@
+/**
+ * External dependencies
+ */
+import { Button, ButtonGroup } from '@wordpress/components';
+import { useKeyboardShortcut } from '@wordpress/compose';
+import { useImperativeHandle, useRef, useEffect, useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import {
+ Icon,
+ closeSmall,
+ check,
+ arrowUp,
+ trash,
+ reusableBlock as regenerate,
+} from '@wordpress/icons';
+import debugFactory from 'debug';
+import React, { forwardRef } from 'react';
+/**
+ * Internal dependencies
+ */
+import { GuidelineMessage } from '../message/index.js';
+import AIControl from './ai-control.js';
+import './style.scss';
+/**
+ * Types
+ */
+import type { RequestingStateProp } from '../../types.js';
+import type { ReactElement } from 'react';
+
+type BlockAIControlProps = {
+ disabled?: boolean;
+ value: string;
+ placeholder?: string;
+ showAccept?: boolean;
+ acceptLabel?: string;
+ showButtonLabels?: boolean;
+ isTransparent?: boolean;
+ state?: RequestingStateProp;
+ showGuideLine?: boolean;
+ customFooter?: ReactElement;
+ onChange?: ( newValue: string ) => void;
+ onSend?: ( currentValue: string ) => void;
+ onStop?: () => void;
+ onAccept?: () => void;
+ onDiscard?: () => void;
+ showRemove?: boolean;
+ banner?: ReactElement;
+ error?: ReactElement;
+};
+
+const debug = debugFactory( 'jetpack-ai-client:block-ai-control' );
+
+/**
+ * BlockAIControl component. Used by the AI Assistant block, adding logic and components to the base AIControl component.
+ *
+ * @param {BlockAIControlProps} props - Component props
+ * @param {React.MutableRefObject} ref - Ref to the component
+ * @returns {ReactElement} Rendered component
+ */
+export function BlockAIControl(
+ {
+ disabled = false,
+ value = '',
+ placeholder = '',
+ showAccept = false,
+ acceptLabel = __( 'Accept', 'jetpack-ai-client' ),
+ showButtonLabels = true,
+ isTransparent = false,
+ state = 'init',
+ showGuideLine = false,
+ customFooter = null,
+ onChange,
+ onSend,
+ onStop,
+ onAccept,
+ onDiscard,
+ showRemove = false,
+ banner = null,
+ error = null,
+ }: BlockAIControlProps,
+ ref: React.MutableRefObject< HTMLInputElement >
+): ReactElement {
+ const loading = state === 'requesting' || state === 'suggesting';
+ const [ editRequest, setEditRequest ] = useState( false );
+ const [ lastValue, setLastValue ] = useState( value || null );
+ const promptUserInputRef = useRef( null );
+
+ // Pass the ref to forwardRef.
+ useImperativeHandle( ref, () => promptUserInputRef.current );
+
+ useEffect( () => {
+ if ( editRequest ) {
+ promptUserInputRef?.current?.focus();
+ }
+ }, [ editRequest ] );
+
+ const sendHandler = useCallback( () => {
+ setLastValue( value );
+ setEditRequest( false );
+ onSend?.( value );
+ }, [ value ] );
+
+ const changeHandler = useCallback(
+ ( newValue: string ) => {
+ onChange?.( newValue );
+ if ( state === 'init' ) {
+ return;
+ }
+
+ if ( ! lastValue ) {
+ // here we're coming from a one-click action
+ setEditRequest( newValue.length > 0 );
+ } else {
+ // here we're coming from an edit action
+ setEditRequest( newValue !== lastValue );
+ }
+ },
+ [ lastValue, state ]
+ );
+
+ const discardHandler = useCallback( () => {
+ onDiscard?.();
+ }, [] );
+
+ const cancelEdit = useCallback( () => {
+ debug( 'cancelEdit, revert to last value', lastValue );
+ onChange?.( lastValue || '' );
+ setEditRequest( false );
+ }, [ lastValue ] );
+
+ useKeyboardShortcut(
+ 'mod+enter',
+ () => {
+ if ( showAccept ) {
+ onAccept?.();
+ }
+ },
+ {
+ target: promptUserInputRef,
+ }
+ );
+
+ useKeyboardShortcut(
+ 'enter',
+ e => {
+ e.preventDefault();
+ sendHandler();
+ },
+ {
+ target: promptUserInputRef,
+ }
+ );
+
+ const actions = (
+ <>
+ { ( ! showAccept || editRequest ) && (
+
+ { ! loading ? (
+ <>
+ { editRequest && (
+
+ ) }
+
+ { showRemove && ! editRequest && ! value?.length && onDiscard && (
+
+ ) }
+
+ { value?.length > 0 && (
+
+ ) }
+ >
+ ) : (
+
+ ) }
+
+ ) }
+ { showAccept && ! editRequest && (
+
+ { ( value?.length > 0 || lastValue === null ) && (
+
+
+
+
+ ) }
+
+
+ ) }
+ >
+ );
+
+ const message =
+ showGuideLine && ! loading && ! editRequest && ( customFooter || );
+
+ return (
+
+ );
+}
+
+export default forwardRef( BlockAIControl );
diff --git a/projects/js-packages/ai-client/src/components/ai-control/extension-ai-control.tsx b/projects/js-packages/ai-client/src/components/ai-control/extension-ai-control.tsx
new file mode 100644
index 0000000000000..3389871d052ed
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/extension-ai-control.tsx
@@ -0,0 +1,217 @@
+/**
+ * External dependencies
+ */
+import { Button, ButtonGroup } from '@wordpress/components';
+import { useKeyboardShortcut } from '@wordpress/compose';
+import { useImperativeHandle, useRef, useEffect, useCallback, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Icon, closeSmall, arrowUp, undo } from '@wordpress/icons';
+import React, { forwardRef } from 'react';
+/**
+ * Internal dependencies
+ */
+import { GuidelineMessage, ErrorMessage, UpgradeMessage } from '../message/index.js';
+import AIControl from './ai-control.js';
+import './style.scss';
+/**
+ * Types
+ */
+import type { RequestingStateProp } from '../../types.js';
+import type { ReactElement } from 'react';
+
+type ExtensionAIControlProps = {
+ disabled?: boolean;
+ value: string;
+ placeholder?: string;
+ showButtonLabels?: boolean;
+ isTransparent?: boolean;
+ state?: RequestingStateProp;
+ showGuideLine?: boolean;
+ error?: string;
+ requestsRemaining?: number;
+ showUpgradeMessage?: boolean;
+ onChange?: ( newValue: string ) => void;
+ onSend?: ( currentValue: string ) => void;
+ onStop?: () => void;
+ onClose?: () => void;
+ onUndo?: () => void;
+ onUpgrade?: () => void;
+};
+
+/**
+ * ExtensionAIControl component. Used by the AI Assistant inline extensions, adding logic and components to the base AIControl component.
+ *
+ * @param {ExtensionAIControlProps} props - Component props
+ * @param {React.MutableRefObject} ref - Ref to the component
+ * @returns {ReactElement} Rendered component
+ */
+export function ExtensionAIControl(
+ {
+ disabled = false,
+ value = '',
+ placeholder = '',
+ showButtonLabels = true,
+ isTransparent = false,
+ state = 'init',
+ showGuideLine = false,
+ error,
+ requestsRemaining,
+ showUpgradeMessage = false,
+ onChange,
+ onSend,
+ onStop,
+ onClose,
+ onUndo,
+ onUpgrade,
+ }: ExtensionAIControlProps,
+ ref: React.MutableRefObject< HTMLInputElement >
+): ReactElement {
+ const loading = state === 'requesting' || state === 'suggesting';
+ const [ editRequest, setEditRequest ] = useState( false );
+ const [ lastValue, setLastValue ] = useState( value || null );
+ const promptUserInputRef = useRef( null );
+
+ // Pass the ref to forwardRef.
+ useImperativeHandle( ref, () => promptUserInputRef.current );
+
+ useEffect( () => {
+ if ( editRequest ) {
+ promptUserInputRef?.current?.focus();
+ }
+ }, [ editRequest ] );
+
+ const sendHandler = useCallback( () => {
+ setLastValue( value );
+ setEditRequest( false );
+ onSend?.( value );
+ }, [ onSend, value ] );
+
+ const changeHandler = useCallback(
+ ( newValue: string ) => {
+ onChange?.( newValue );
+ if ( state === 'init' ) {
+ return;
+ }
+
+ if ( ! lastValue ) {
+ // here we're coming from a one-click action
+ setEditRequest( newValue.length > 0 );
+ } else {
+ // here we're coming from an edit action
+ setEditRequest( newValue !== lastValue );
+ }
+ },
+ [ onChange, lastValue, state ]
+ );
+
+ const stopHandler = useCallback( () => {
+ onStop?.();
+ }, [ onStop ] );
+
+ const closeHandler = useCallback( () => {
+ onClose?.();
+ }, [ onClose ] );
+
+ const undoHandler = useCallback( () => {
+ onUndo?.();
+ }, [ onUndo ] );
+
+ const upgradeHandler = useCallback( () => {
+ onUpgrade?.();
+ }, [ onUpgrade ] );
+
+ useKeyboardShortcut(
+ 'enter',
+ e => {
+ e.preventDefault();
+ sendHandler();
+ },
+ {
+ target: promptUserInputRef,
+ }
+ );
+
+ const actions = (
+ <>
+ { loading ? (
+
+ ) : (
+ <>
+ { value?.length > 0 && (
+
+
+
+ ) }
+ { value?.length <= 0 && state === 'done' && (
+
+
+
+
+
+
+ ) }
+ >
+ ) }
+ >
+ );
+
+ let message = null;
+ if ( error ) {
+ message = ;
+ } else if ( showUpgradeMessage ) {
+ message = (
+
+ );
+ } else if ( showGuideLine ) {
+ message = ;
+ }
+
+ return (
+
+ );
+}
+
+export default forwardRef( ExtensionAIControl );
diff --git a/projects/js-packages/ai-client/src/components/ai-control/index.tsx b/projects/js-packages/ai-client/src/components/ai-control/index.tsx
index 0f99c97f84e92..74ac53f165e8a 100644
--- a/projects/js-packages/ai-client/src/components/ai-control/index.tsx
+++ b/projects/js-packages/ai-client/src/components/ai-control/index.tsx
@@ -1,281 +1,3 @@
-/**
- * External dependencies
- */
-import { PlainText } from '@wordpress/block-editor';
-import { Button, ButtonGroup } from '@wordpress/components';
-import { useKeyboardShortcut } from '@wordpress/compose';
-import { useImperativeHandle, useRef, useEffect, useCallback } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
-import { Icon, closeSmall, check, arrowUp, trash, reusableBlock } from '@wordpress/icons';
-import classNames from 'classnames';
-import debugFactory from 'debug';
-import React, { forwardRef } from 'react';
-/**
- * Internal dependencies
- */
-import './style.scss';
-import AiStatusIndicator from '../ai-status-indicator/index.js';
-import { GuidelineMessage } from './message.js';
-/**
- * Types
- */
-import type { RequestingStateProp } from '../../types.js';
-import type { ReactElement } from 'react';
-type AiControlProps = {
- disabled?: boolean;
- value: string;
- placeholder?: string;
- showAccept?: boolean;
- acceptLabel?: string;
- showButtonLabels?: boolean;
- isTransparent?: boolean;
- state?: RequestingStateProp;
- showGuideLine?: boolean;
- customFooter?: ReactElement;
- onChange?: ( newValue: string ) => void;
- onSend?: ( currentValue: string ) => void;
- onStop?: () => void;
- onAccept?: () => void;
- onDiscard?: () => void;
- showRemove?: boolean;
- bannerComponent?: ReactElement;
- errorComponent?: ReactElement;
-};
-
-// eslint-disable-next-line @typescript-eslint/no-empty-function
-const noop = () => {};
-
-const debug = debugFactory( 'jetpack-ai-client:ai-control' );
-
-/**
- * AI Control component.
- *
- * @param {AiControlProps} props - Component props.
- * @param {React.MutableRefObject} ref - Ref to the component.
- * @returns {ReactElement} Rendered component.
- */
-export function AIControl(
- {
- disabled = false,
- value = '',
- placeholder = '',
- showAccept = false,
- acceptLabel = __( 'Accept', 'jetpack-ai-client' ),
- showButtonLabels = true,
- isTransparent = false,
- state = 'init',
- showGuideLine = false,
- customFooter = null,
- onChange = noop,
- onSend = noop,
- onStop = noop,
- onAccept = noop,
- onDiscard = null,
- showRemove = false,
- bannerComponent = null,
- errorComponent = null,
- }: AiControlProps,
- ref: React.MutableRefObject< HTMLInputElement >
-): ReactElement {
- const promptUserInputRef = useRef( null );
- const loading = state === 'requesting' || state === 'suggesting';
- const [ editRequest, setEditRequest ] = React.useState( false );
- const [ lastValue, setLastValue ] = React.useState( value || null );
-
- useEffect( () => {
- if ( editRequest ) {
- promptUserInputRef?.current?.focus();
- }
- }, [ editRequest ] );
-
- const sendRequest = useCallback( () => {
- setLastValue( value );
- setEditRequest( false );
- onSend?.( value );
- }, [ value ] );
-
- const changeHandler = useCallback(
- ( newValue: string ) => {
- onChange?.( newValue );
- if ( state === 'init' ) {
- return;
- }
-
- if ( ! lastValue ) {
- // here we're coming from a one-click action
- setEditRequest( newValue.length > 0 );
- } else {
- // here we're coming from an edit action
- setEditRequest( newValue !== lastValue );
- }
- },
- [ lastValue, state ]
- );
-
- const discardHandler = useCallback( () => {
- onDiscard?.();
- }, [] );
-
- const cancelEdit = useCallback( () => {
- debug( 'cancelEdit, revert to last value', lastValue );
- onChange( lastValue || '' );
- setEditRequest( false );
- }, [ lastValue ] );
-
- // Pass the ref to forwardRef.
- useImperativeHandle( ref, () => promptUserInputRef.current );
-
- useKeyboardShortcut(
- 'mod+enter',
- () => {
- if ( showAccept ) {
- onAccept?.();
- }
- },
- {
- target: promptUserInputRef,
- }
- );
-
- useKeyboardShortcut(
- 'enter',
- e => {
- e.preventDefault();
- sendRequest();
- },
- {
- target: promptUserInputRef,
- }
- );
-
- return (
-
- { errorComponent }
-
- { bannerComponent }
-
-
-
-
-
- { ( ! showAccept || editRequest ) && (
-
- { ! loading ? (
- <>
- { editRequest && (
-
- ) }
-
- { showRemove && ! editRequest && ! value?.length && onDiscard && (
-
- ) }
-
- { value?.length > 0 && (
-
- ) }
- >
- ) : (
-
- ) }
-
- ) }
-
- { showAccept && ! editRequest && (
-
- { ( value?.length > 0 || lastValue === null ) && (
-
-
-
-
- ) }
-
-
- ) }
-
- { showGuideLine && ! loading && ! editRequest && ( customFooter ||
) }
-
-
- );
-}
-
-export default forwardRef( AIControl );
+export { default as AIControl } from './ai-control.js';
+export { default as BlockAIControl } from './block-ai-control.js';
+export { default as ExtensionAIControl } from './extension-ai-control.js';
diff --git a/projects/js-packages/ai-client/src/components/ai-control/message.tsx b/projects/js-packages/ai-client/src/components/ai-control/message.tsx
deleted file mode 100644
index cd291e58fbd11..0000000000000
--- a/projects/js-packages/ai-client/src/components/ai-control/message.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * External dependencies
- */
-import { ExternalLink, Button } from '@wordpress/components';
-import { createInterpolateElement } from '@wordpress/element';
-import { __, sprintf } from '@wordpress/i18n';
-import {
- Icon,
- warning,
- info,
- cancelCircleFilled as error,
- check as success,
-} from '@wordpress/icons';
-/**
- * Types
- */
-import type React from 'react';
-
-import './style.scss';
-
-export const MESSAGE_SEVERITY_WARNING = 'warning';
-export const MESSAGE_SEVERITY_ERROR = 'error';
-export const MESSAGE_SEVERITY_SUCCESS = 'success';
-export const MESSAGE_SEVERITY_INFO = 'info';
-
-const messageSeverityTypes = [
- MESSAGE_SEVERITY_WARNING,
- MESSAGE_SEVERITY_ERROR,
- MESSAGE_SEVERITY_SUCCESS,
- MESSAGE_SEVERITY_INFO,
-] as const;
-
-export type MessageSeverityProp = ( typeof messageSeverityTypes )[ number ] | null;
-
-export type MessageProps = {
- icon?: React.ReactNode;
- children: React.ReactNode;
- severity: MessageSeverityProp;
-};
-
-const messageIconsMap = {
- [ MESSAGE_SEVERITY_WARNING ]: warning,
- [ MESSAGE_SEVERITY_ERROR ]: error,
- [ MESSAGE_SEVERITY_SUCCESS ]: success,
- [ MESSAGE_SEVERITY_INFO ]: info,
-};
-
-/**
- * React component to render a block message.
- *
- * @param {MessageProps} props - Component props.
- * @returns {React.ReactElement } Banner component.
- */
-export default function Message( {
- severity = null,
- icon = null,
- children,
-}: MessageProps ): React.ReactElement {
- return (
-
- { ( severity || icon ) &&
}
-
{ children }
-
- );
-}
-
-/**
- * React component to render a guideline message.
- *
- * @returns {React.ReactElement } - Message component.
- */
-export function GuidelineMessage(): React.ReactElement {
- return (
-
- { createInterpolateElement(
- __(
- 'AI-generated content could be inaccurate or biased. Learn more',
- 'jetpack-ai-client'
- ),
- {
- link: ,
- }
- ) }
-
- );
-}
-
-/**
- * React component to render a upgrade message.
- *
- * @param {number} requestsRemaining - Number of requests remaining.
- * @returns {React.ReactElement } - Message component.
- */
-export function UpgradeMessage( {
- requestsRemaining,
- onUpgradeClick,
-}: {
- requestsRemaining: number;
- onUpgradeClick: () => void;
-} ): React.ReactElement {
- return (
-
- { createInterpolateElement(
- sprintf(
- // translators: %1$d: number of requests remaining
- __(
- 'You have %1$d free requests remaining. Upgrade and avoid interruptions',
- 'jetpack-ai-client'
- ),
- requestsRemaining
- ),
- {
- link: ,
- }
- ) }
-
- );
-}
diff --git a/projects/js-packages/ai-client/src/components/ai-control/stories/ai-control.stories.tsx b/projects/js-packages/ai-client/src/components/ai-control/stories/ai-control.stories.tsx
new file mode 100644
index 0000000000000..a38d9b51c6b4d
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/stories/ai-control.stories.tsx
@@ -0,0 +1,101 @@
+/**
+ * External dependencies
+ */
+import { action } from '@storybook/addon-actions';
+import { Button, Notice } from '@wordpress/components';
+import { useState } from '@wordpress/element';
+import React from 'react';
+/**
+ * Internal dependencies
+ */
+import { GuidelineMessage, ErrorMessage, UpgradeMessage } from '../../message/index.js';
+import { AIControl } from '../index.js';
+
+export default {
+ title: 'JS Packages/AI Client/AI Control',
+ component: AIControl,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ state: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'init', 'requesting', 'suggesting', 'done', 'error' ],
+ },
+ message: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'None', 'Guideline message', 'Error message', 'Upgrade message' ],
+ mapping: {
+ None: null,
+ 'Guideline message': ,
+ 'Error message': ,
+ 'Upgrade message': (
+
+ ),
+ },
+ },
+ actions: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'None', 'Accept button' ],
+ mapping: {
+ None: null,
+ 'Accept button': ,
+ },
+ },
+ error: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'None', 'Error notice' ],
+ mapping: {
+ None: null,
+ 'Error notice': (
+
+ Error message
+
+ ),
+ },
+ },
+ },
+ parameters: {
+ controls: {
+ exclude: /on[A-Z].*/,
+ },
+ },
+};
+
+const DefaultTemplate = args => {
+ const [ value, setValue ] = useState( '' );
+
+ const handleChange = ( newValue: string ) => {
+ setValue( newValue );
+ args?.onChange?.( newValue );
+ };
+
+ return ;
+};
+
+const DefaultArgs = {
+ placeholder: 'Placeholder',
+ disabled: false,
+ isTransparent: false,
+ state: 'init',
+ onChange: action( 'onChange' ),
+ message: null,
+ banner: null,
+ error: null,
+ actions: null,
+};
+
+export const Default = DefaultTemplate.bind( {} );
+Default.args = DefaultArgs;
diff --git a/projects/js-packages/ai-client/src/components/ai-control/stories/block-ai-control.stories.tsx b/projects/js-packages/ai-client/src/components/ai-control/stories/block-ai-control.stories.tsx
new file mode 100644
index 0000000000000..ad1dbb3c44cd7
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/stories/block-ai-control.stories.tsx
@@ -0,0 +1,139 @@
+/**
+ * External dependencies
+ */
+import { action } from '@storybook/addon-actions';
+import { useArgs } from '@storybook/preview-api';
+import { Notice } from '@wordpress/components';
+import React from 'react';
+/**
+ * Internal dependencies
+ */
+import { GuidelineMessage, ErrorMessage, UpgradeMessage } from '../../message/index.js';
+import { BlockAIControl } from '../index.js';
+
+export default {
+ title: 'JS Packages/AI Client/AI Control/Block AI Control',
+ component: BlockAIControl,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ state: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'init', 'requesting', 'suggesting', 'done', 'error' ],
+ },
+ error: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'None', 'Error notice' ],
+ mapping: {
+ None: null,
+ 'Error notice': (
+
+ Error message
+
+ ),
+ },
+ },
+ customFooter: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'None', 'Guideline message', 'Error message', 'Upgrade message' ],
+ mapping: {
+ None: null,
+ 'Guideline message': ,
+ 'Error message': ,
+ 'Upgrade message': (
+
+ ),
+ },
+ },
+ },
+ parameters: {
+ controls: {
+ exclude: /on[A-Z].*/,
+ },
+ },
+};
+
+const DefaultTemplate = args => {
+ const [ { value }, updateArgs, resetArgs ] = useArgs();
+
+ const handleChange = ( newValue: string ) => {
+ updateArgs( { value: newValue, showAccept: false } );
+ args?.onChange?.( newValue );
+ };
+
+ const handleSend = () => {
+ updateArgs( { state: 'requesting', error: null, showAccept: false } );
+
+ setTimeout( () => {
+ updateArgs( { state: 'suggesting' } );
+
+ setTimeout( () => {
+ updateArgs( { state: 'done', showAccept: true } );
+ }, 3000 );
+ }, 1000 );
+
+ args?.onSend?.( value );
+ };
+
+ const handleStop = () => {
+ updateArgs( { state: 'done', error: null, showAccept: true } );
+ args?.onStop?.();
+ };
+
+ const handleAccept = () => {
+ resetArgs();
+ args?.onAccept?.();
+ };
+
+ const handleDiscard = () => {
+ resetArgs();
+ args?.onDiscard?.();
+ };
+
+ return (
+
+ );
+};
+
+const DefaultArgs = {
+ value: '',
+ placeholder: 'Placeholder',
+ acceptLabel: 'Accept',
+ showButtonLabels: true,
+ disabled: false,
+ isTransparent: false,
+ state: 'init',
+ showAccept: false,
+ showGuideLine: true,
+ customFooter: null,
+ onChange: action( 'onChange' ),
+ onSend: action( 'onSend' ),
+ onStop: action( 'onStop' ),
+ onAccept: action( 'onAccept' ),
+ onDiscard: action( 'onDiscard' ),
+ showRemove: false,
+ banner: null,
+ error: null,
+};
+
+export const Default = DefaultTemplate.bind( {} );
+Default.args = DefaultArgs;
diff --git a/projects/js-packages/ai-client/src/components/ai-control/stories/extension-ai-control.stories.tsx b/projects/js-packages/ai-client/src/components/ai-control/stories/extension-ai-control.stories.tsx
new file mode 100644
index 0000000000000..0a67bb6ad0369
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/ai-control/stories/extension-ai-control.stories.tsx
@@ -0,0 +1,124 @@
+/**
+ * External dependencies
+ */
+import { action } from '@storybook/addon-actions';
+import { useArgs } from '@storybook/preview-api';
+import React from 'react';
+/**
+ * Internal dependencies
+ */
+import { ExtensionAIControl } from '../index.js';
+
+export default {
+ title: 'JS Packages/AI Client/AI Control/Extension AI Control',
+ component: ExtensionAIControl,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ state: {
+ control: {
+ type: 'select',
+ },
+ options: [ 'init', 'requesting', 'suggesting', 'done', 'error' ],
+ },
+ error: {
+ control: {
+ type: 'text',
+ },
+ },
+ requestsRemaining: {
+ control: {
+ type: 'number',
+ },
+ },
+ },
+ parameters: {
+ controls: {
+ exclude: /on[A-Z].*/,
+ },
+ },
+};
+
+const DefaultTemplate = args => {
+ const [ { value }, updateArgs, resetArgs ] = useArgs();
+
+ const handleChange = ( newValue: string ) => {
+ updateArgs( { value: newValue } );
+ args?.onChange?.( newValue );
+ };
+
+ const handleSend = () => {
+ updateArgs( { state: 'requesting', error: null, value: '', placeholder: value } );
+
+ setTimeout( () => {
+ updateArgs( { state: 'suggesting' } );
+
+ setTimeout( () => {
+ updateArgs( { state: 'done' } );
+ }, 3000 );
+ }, 1000 );
+
+ args?.onSend?.( value );
+ };
+
+ const handleStop = () => {
+ updateArgs( { state: 'done', error: null } );
+ args?.onStop?.();
+ };
+
+ const handleClose = () => {
+ resetArgs();
+ args?.onClose?.();
+ resetArgs();
+ };
+
+ const handleUndo = () => {
+ resetArgs();
+ args?.onUndo?.();
+ resetArgs();
+ };
+
+ const handleUpgrade = () => {
+ args?.onUpgrade?.();
+ };
+
+ return (
+
+ );
+};
+
+const DefaultArgs = {
+ value: '',
+ placeholder: 'Placeholder',
+ showButtonLabels: true,
+ disabled: false,
+ isTransparent: false,
+ state: 'init',
+ showGuideLine: false,
+ error: null,
+ requestsRemaining: null,
+ showUpgradeMessage: false,
+ onChange: action( 'onChange' ),
+ onSend: action( 'onSend' ),
+ onStop: action( 'onStop' ),
+ onClose: action( 'onClose' ),
+ onUndo: action( 'onUndo' ),
+ onUpgrade: action( 'onUpgrade' ),
+};
+
+export const Default = DefaultTemplate.bind( {} );
+Default.args = DefaultArgs;
diff --git a/projects/js-packages/ai-client/src/components/ai-control/stories/index.stories.tsx b/projects/js-packages/ai-client/src/components/ai-control/stories/index.stories.tsx
deleted file mode 100644
index 7ff4237aedf06..0000000000000
--- a/projects/js-packages/ai-client/src/components/ai-control/stories/index.stories.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * External dependencies
- */
-import { action } from '@storybook/addon-actions';
-import { useState } from '@wordpress/element';
-import React from 'react';
-/**
- * Internal dependencies
- */
-import AIControl from '../index.js';
-/**
- * Types
- */
-import type { Meta } from '@storybook/react';
-
-interface AIControlStoryMeta extends Meta< typeof AIControl > {
- title?: string;
- component?: React.ReactElement;
-}
-
-const meta: AIControlStoryMeta = {
- title: 'JS Packages/AI Client/AI Control',
- component: AIControl,
- decorators: [
- Story => (
-
-
-
- ),
- ],
- argTypes: {
- state: {
- control: {
- type: 'select',
- },
- options: [ 'init', 'requesting', 'suggesting', 'done', 'error' ],
- },
- },
- parameters: {
- controls: {
- exclude: /on[A-Z].*/,
- },
- },
-} as Meta< typeof AIControl >;
-
-const Template = args => {
- const [ value, setValue ] = useState( '' );
-
- const handleChange = ( newValue: string ) => {
- setValue( newValue );
- args?.onChange?.( newValue );
- };
-
- return ;
-};
-
-const DefaultArgs = {
- isTransparent: false,
- placeholder: '',
- state: 'init',
- showButtonLabels: true,
- showAccept: false,
- acceptLabel: 'Accept',
- onChange: action( 'onChange' ),
- onSend: action( 'onSend' ),
- onStop: action( 'onStop' ),
- onAccept: action( 'onAccept' ),
-};
-
-export const Default = Template.bind( {} );
-Default.args = DefaultArgs;
-
-export default meta;
diff --git a/projects/js-packages/ai-client/src/components/ai-control/style.scss b/projects/js-packages/ai-client/src/components/ai-control/style.scss
index 7d3f43e87c658..f62d514fad148 100644
--- a/projects/js-packages/ai-client/src/components/ai-control/style.scss
+++ b/projects/js-packages/ai-client/src/components/ai-control/style.scss
@@ -1,7 +1,5 @@
@import '@automattic/jetpack-base-styles/root-variables';
-// AI CONTROL
-
.jetpack-components-ai-control__container-wrapper {
position: sticky;
bottom: 16px;
@@ -72,12 +70,6 @@
}
.jetpack-components-ai-control__controls-prompt_button_wrapper {
- text-transform: uppercase;
- font-size: 11px;
- font-weight: 600;
- line-height: 1em;
- user-select: none;
- white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
@@ -90,6 +82,10 @@
box-shadow: none;
padding: 6px 8px;
}
+
+ .components-button-group {
+ display: flex;
+ }
}
.jetpack-components-ai-control__controls-prompt_button {
@@ -98,37 +94,3 @@
cursor: not-allowed;
}
}
-
-// MESSAGE
-
-.jetpack-ai-assistant__message {
- display: flex;
- line-height: 28px;
- font-size: 12px;
- align-self: center;
- align-items: center;
- background-color: var( --jp-white-off );
- padding: 0 12px;
- border-radius: 0 0 6px 6px;
-
- > svg {
- fill: var( --jp-gray-40 );
- }
-
- .jetpack-ai-assistant__message-content {
- flex-grow: 2;
- margin: 0 8px;
- color: var( --jp-gray-50 );
- line-height: 1.4em;
-
- .components-external-link {
- color: var( --jp-gray-50 );
- }
-
- // Force padding 0 in link buttons, since default Gutenberg version in WordPress doesn't use iframe and
- // Buttons receive styles from edit-post-visual-editor.
- .components-button.is-link {
- padding: 0;
- }
- }
-}
diff --git a/projects/js-packages/ai-client/src/components/index.ts b/projects/js-packages/ai-client/src/components/index.ts
index e4e7e9da14e23..a8d290f28d05b 100644
--- a/projects/js-packages/ai-client/src/components/index.ts
+++ b/projects/js-packages/ai-client/src/components/index.ts
@@ -1,8 +1,9 @@
-export { default as AIControl } from './ai-control/index.js';
+export { AIControl, BlockAIControl } from './ai-control/index.js';
export { default as AiStatusIndicator } from './ai-status-indicator/index.js';
export { default as AudioDurationDisplay } from './audio-duration-display/index.js';
export {
GuidelineMessage,
UpgradeMessage,
+ ErrorMessage,
default as FooterMessage,
-} from './ai-control/message.js';
+} from './message/index.js';
diff --git a/projects/js-packages/ai-client/src/components/message/index.tsx b/projects/js-packages/ai-client/src/components/message/index.tsx
new file mode 100644
index 0000000000000..ce86598f8a183
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/message/index.tsx
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import { ExternalLink, Button } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { Icon, check, arrowRight } from '@wordpress/icons';
+import classNames from 'classnames';
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import errorExclamation from '../../icons/error-exclamation.js';
+/**
+ * Types
+ */
+import type React from 'react';
+
+export const MESSAGE_SEVERITY_WARNING = 'warning';
+export const MESSAGE_SEVERITY_ERROR = 'error';
+export const MESSAGE_SEVERITY_SUCCESS = 'success';
+export const MESSAGE_SEVERITY_INFO = 'info';
+
+const messageSeverityTypes = [
+ MESSAGE_SEVERITY_WARNING,
+ MESSAGE_SEVERITY_ERROR,
+ MESSAGE_SEVERITY_SUCCESS,
+ MESSAGE_SEVERITY_INFO,
+] as const;
+
+export type MessageSeverityProp = ( typeof messageSeverityTypes )[ number ] | null;
+
+export type MessageProps = {
+ icon?: React.ReactNode;
+ severity?: MessageSeverityProp;
+ showSidebarIcon?: boolean;
+ onSidebarIconClick?: () => void;
+ children: React.ReactNode;
+};
+
+export type UpgradeMessageProps = {
+ requestsRemaining: number;
+ onUpgradeClick: () => void;
+};
+
+export type ErrorMessageProps = {
+ error?: string;
+ onTryAgainClick: () => void;
+};
+
+const messageIconsMap = {
+ [ MESSAGE_SEVERITY_INFO ]: null,
+ [ MESSAGE_SEVERITY_WARNING ]: null,
+ [ MESSAGE_SEVERITY_ERROR ]: errorExclamation,
+ [ MESSAGE_SEVERITY_SUCCESS ]: check,
+};
+
+/**
+ * React component to render a block message.
+ *
+ * @param {MessageProps} props - Component props.
+ * @returns {React.ReactElement } Banner component.
+ */
+export default function Message( {
+ severity = MESSAGE_SEVERITY_INFO,
+ icon = null,
+ showSidebarIcon = false,
+ onSidebarIconClick = () => {},
+ children,
+}: MessageProps ): React.ReactElement {
+ return (
+
+ { ( messageIconsMap[ severity ] || icon ) && (
+
+ ) }
+
{ children }
+ { showSidebarIcon && (
+
+ ) }
+
+ );
+}
+
+/**
+ * React component to render a guideline message.
+ *
+ * @returns {React.ReactElement } - Message component.
+ */
+export function GuidelineMessage(): React.ReactElement {
+ return (
+
+
+ { __( 'AI-generated content could be inaccurate or biased.', 'jetpack-ai-client' ) }
+
+
+ { __( 'Learn more', 'jetpack-ai-client' ) }
+
+
+ );
+}
+
+/**
+ * React component to render an upgrade message for free tier users
+ *
+ * @param {number} requestsRemaining - Number of requests remaining.
+ * @returns {React.ReactElement } - Message component.
+ */
+export function UpgradeMessage( {
+ requestsRemaining,
+ onUpgradeClick,
+}: UpgradeMessageProps ): React.ReactElement {
+ return (
+
+
+ { sprintf(
+ // translators: %1$d: number of requests remaining
+ __( 'You have %1$d free requests remaining.', 'jetpack-ai-client' ),
+ requestsRemaining
+ ) }
+
+
+
+ );
+}
+
+/**
+ * React component to render an error message
+ *
+ * @param {number} requestsRemaining - Number of requests remaining.
+ * @returns {React.ReactElement } - Message component.
+ */
+export function ErrorMessage( { error, onTryAgainClick }: ErrorMessageProps ): React.ReactElement {
+ const errorMessage = error || __( 'Something went wrong', 'jetpack-ai-client' );
+
+ return (
+
+
+ { sprintf(
+ // translators: %1$d: A dynamic error message
+ __( 'Error: %1$s.', 'jetpack-ai-client' ),
+ errorMessage
+ ) }
+
+
+
+ );
+}
diff --git a/projects/js-packages/ai-client/src/components/message/stories/index.stories.tsx b/projects/js-packages/ai-client/src/components/message/stories/index.stories.tsx
new file mode 100644
index 0000000000000..bba54c0560fd1
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/message/stories/index.stories.tsx
@@ -0,0 +1,68 @@
+/**
+ * External dependencies
+ */
+import { action } from '@storybook/addon-actions';
+import React from 'react';
+/**
+ * Internal dependencies
+ */
+import Message, { GuidelineMessage, UpgradeMessage, ErrorMessage } from '../index.js';
+
+export default {
+ title: 'JS Packages/AI Client/Message',
+ component: Message,
+ decorators: [
+ Story => (
+
+
+
+ ),
+ ],
+};
+
+const DefaultTemplate = args => {
+ return ;
+};
+
+const DefaultArgs = {
+ children: Message,
+};
+
+export const Default = DefaultTemplate.bind( {} );
+Default.args = DefaultArgs;
+
+const GuidelineTemplate = args => {
+ return ;
+};
+
+const GuidelineArgs = {};
+
+export const Guideline = GuidelineTemplate.bind( {} );
+Guideline.args = GuidelineArgs;
+
+const UpgradeTemplate = args => {
+ return (
+
+ );
+};
+
+const UpgradeArgs = {
+ requestsRemaining: 10,
+};
+
+export const Upgrade = UpgradeTemplate.bind( {} );
+Upgrade.args = UpgradeArgs;
+
+const ErrorTemplate = args => {
+ return ;
+};
+
+const ErrorArgs = {
+ error: 'An error occurred',
+};
+
+export const Error = ErrorTemplate.bind( {} );
+Error.args = ErrorArgs;
diff --git a/projects/js-packages/ai-client/src/components/message/style.scss b/projects/js-packages/ai-client/src/components/message/style.scss
new file mode 100644
index 0000000000000..ded7c9e3e6519
--- /dev/null
+++ b/projects/js-packages/ai-client/src/components/message/style.scss
@@ -0,0 +1,83 @@
+@import '@automattic/jetpack-base-styles/root-variables';
+
+.jetpack-ai-assistant__message {
+ display: flex;
+ line-height: 28px;
+ font-size: 12px;
+ align-self: center;
+ align-items: center;
+ padding: 0 12px;
+ border-radius: 4px;
+ min-height: 28px;
+
+ > svg {
+ fill: var( --jp-gray-40 );
+ flex-shrink: 0;
+ }
+
+ .jetpack-ai-assistant__message-content {
+ flex-grow: 2;
+ margin: 0 8px;
+ line-height: 1.4em;
+ display: flex;
+ gap: 4px;
+ align-items: center;
+
+ span {
+ padding: 5px 0;
+ }
+
+ .components-external-link {
+ color: var( --jp-gray-50 );
+ }
+
+ // Force padding 0 in link buttons, since default Gutenberg version in WordPress doesn't use iframe and
+ // Buttons receive styles from edit-post-visual-editor.
+ .components-button.is-link {
+ padding: 0;
+ }
+
+ .components-button.is-link,
+ .components-external-link {
+ flex-shrink: 0;
+ }
+ }
+}
+
+.jetpack-ai-assistant__message-severity-info {
+ background-color: var( --jp-gray-0 );
+ color: var( --jp-gray-50 );
+}
+
+.jetpack-ai-assistant__message-severity-warning {
+ background-color: #FEF8EE;
+ color: var( --jp-gray-100 );
+}
+
+.jetpack-ai-assistant__message-severity-error {
+ background-color: var( --jp-red-0 );
+ color: var( --jp-gray-100 );
+
+ > svg {
+ fill: var( --jp-red-40, #E65054 );
+ }
+}
+
+.jetpack-ai-assistant__message-severity-success {
+ background-color: var( --jp-green-5 );
+ color: var( --jp-gray-100 );
+
+ > svg {
+ fill: var( --jp-green-30 );
+ }
+}
+
+.jetpack-ai-assistant__message-sidebar {
+ display: flex;
+ padding: 0;
+ height: unset;
+
+ > svg {
+ fill: var( --jp-gray-50 );
+ }
+}
diff --git a/projects/js-packages/ai-client/src/icons/error-exclamation.tsx b/projects/js-packages/ai-client/src/icons/error-exclamation.tsx
new file mode 100644
index 0000000000000..6e241d6722f73
--- /dev/null
+++ b/projects/js-packages/ai-client/src/icons/error-exclamation.tsx
@@ -0,0 +1,18 @@
+/**
+ * External dependencies
+ */
+import { SVG, Path } from '@wordpress/components';
+
+const errorExclamation = (
+
+);
+
+export default errorExclamation;
diff --git a/projects/plugins/jetpack/changelog/update-jetpack-ai-input-ui b/projects/plugins/jetpack/changelog/update-jetpack-ai-input-ui
new file mode 100644
index 0000000000000..f70ba1b5bb34d
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/update-jetpack-ai-input-ui
@@ -0,0 +1,4 @@
+Significance: patch
+Type: other
+
+AI Assistant: Update AIControl imports
diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js b/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js
index d584fe05c7756..fb547ef273fab 100644
--- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js
+++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/edit.js
@@ -1,7 +1,11 @@
/**
* External dependencies
*/
-import { AIControl, UpgradeMessage, renderHTMLFromMarkdown } from '@automattic/jetpack-ai-client';
+import {
+ BlockAIControl,
+ UpgradeMessage,
+ renderHTMLFromMarkdown,
+} from '@automattic/jetpack-ai-client';
import { useAnalytics } from '@automattic/jetpack-shared-extension-utils';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { rawHandler } from '@wordpress/blocks';
@@ -49,6 +53,7 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId,
requestsCount,
requestsLimit,
currentTier,
+ loading: loadingAiFeature,
} = useAiFeature();
const requestsRemaining = Math.max( requestsLimit - requestsCount, 0 );
@@ -382,7 +387,7 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId,
isGeneratingTitle={ isGeneratingTitle }
/>
) }
- 0 }
- bannerComponent={ banner }
- errorComponent={ errorNotice }
+ banner={ banner }
+ error={ errorNotice }
customFooter={
// Only show the upgrade message on each 5th request or if it's the first request - and only if the user is on the free plan
( requestsRemaining % 5 === 0 || requestsCount === 1 ) &&
+ ! loadingAiFeature && // Don't show the upgrade message while the feature is loading
planType === PLAN_TYPE_FREE ? (
{ siteRequireUpgrade && }
{ ! connected && }
-