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 } +
+ + +
+ + </div> + { actions } + </div> + { message } + </div> + </div> + ); +} 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 ) && ( + <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> + { ! loading ? ( + <> + { editRequest && ( + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ cancelEdit } + variant="secondary" + label={ __( 'Cancel', 'jetpack-ai-client' ) } + > + { showButtonLabels ? ( + __( 'Cancel', 'jetpack-ai-client' ) + ) : ( + <Icon icon={ closeSmall } /> + ) } + </Button> + ) } + + { showRemove && ! editRequest && ! value?.length && onDiscard && ( + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ discardHandler } + variant="secondary" + label={ __( 'Cancel', 'jetpack-ai-client' ) } + > + { showButtonLabels ? ( + __( 'Cancel', 'jetpack-ai-client' ) + ) : ( + <Icon icon={ closeSmall } /> + ) } + </Button> + ) } + + { value?.length > 0 && ( + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ sendHandler } + variant="primary" + disabled={ ! value?.length || disabled } + label={ __( 'Send request', 'jetpack-ai-client' ) } + > + { showButtonLabels ? ( + __( 'Generate', 'jetpack-ai-client' ) + ) : ( + <Icon icon={ arrowUp } /> + ) } + </Button> + ) } + </> + ) : ( + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ onStop } + variant="secondary" + label={ __( 'Stop request', 'jetpack-ai-client' ) } + > + { showButtonLabels ? ( + __( 'Stop', 'jetpack-ai-client' ) + ) : ( + <Icon icon={ closeSmall } /> + ) } + </Button> + ) } + </div> + ) } + { showAccept && ! editRequest && ( + <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> + { ( value?.length > 0 || lastValue === null ) && ( + <ButtonGroup> + <Button + className="jetpack-components-ai-control__controls-prompt_button" + label={ __( 'Discard', 'jetpack-ai-client' ) } + onClick={ discardHandler } + tooltipPosition="top" + > + <Icon icon={ trash } /> + </Button> + <Button + className="jetpack-components-ai-control__controls-prompt_button" + label={ __( 'Regenerate', 'jetpack-ai-client' ) } + onClick={ () => onSend?.( value ) } + tooltipPosition="top" + disabled={ ! value?.length || value === null || disabled } + > + <Icon icon={ regenerate } /> + </Button> + </ButtonGroup> + ) } + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ onAccept } + variant="primary" + label={ acceptLabel } + > + { showButtonLabels ? acceptLabel : <Icon icon={ check } /> } + </Button> + </div> + ) } + </> + ); + + const message = + showGuideLine && ! loading && ! editRequest && ( customFooter || <GuidelineMessage /> ); + + return ( + <AIControl + disabled={ disabled || loading } + value={ value } + placeholder={ placeholder } + isTransparent={ isTransparent } + state={ state } + onChange={ changeHandler } + banner={ banner } + error={ error } + actions={ actions } + message={ message } + promptUserInputRef={ promptUserInputRef } + /> + ); +} + +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 ? ( + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ stopHandler } + variant="secondary" + label={ __( 'Stop request', 'jetpack-ai-client' ) } + > + { showButtonLabels ? __( 'Stop', 'jetpack-ai-client' ) : <Icon icon={ closeSmall } /> } + </Button> + ) : ( + <> + { value?.length > 0 && ( + <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> + <Button + className="jetpack-components-ai-control__controls-prompt_button" + onClick={ sendHandler } + variant="primary" + disabled={ ! value?.length || disabled } + label={ __( 'Send request', 'jetpack-ai-client' ) } + > + { showButtonLabels ? ( + __( 'Generate', 'jetpack-ai-client' ) + ) : ( + <Icon icon={ arrowUp } /> + ) } + </Button> + </div> + ) } + { value?.length <= 0 && state === 'done' && ( + <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> + <ButtonGroup> + <Button + className="jetpack-components-ai-control__controls-prompt_button" + label={ __( 'Undo', 'jetpack-ai-client' ) } + onClick={ undoHandler } + tooltipPosition="top" + > + <Icon icon={ undo } /> + </Button> + <Button + className="jetpack-components-ai-control__controls-prompt_button" + label={ __( 'Close', 'jetpack-ai-client' ) } + onClick={ closeHandler } + variant="tertiary" + > + { __( 'Close', 'jetpack-ai-client' ) } + </Button> + </ButtonGroup> + </div> + ) } + </> + ) } + </> + ); + + let message = null; + if ( error ) { + message = <ErrorMessage error={ error } onTryAgainClick={ sendHandler } />; + } else if ( showUpgradeMessage ) { + message = ( + <UpgradeMessage requestsRemaining={ requestsRemaining } onUpgradeClick={ upgradeHandler } /> + ); + } else if ( showGuideLine ) { + message = <GuidelineMessage />; + } + + return ( + <AIControl + disabled={ disabled || loading } + value={ value } + placeholder={ placeholder } + isTransparent={ isTransparent } + state={ state } + onChange={ changeHandler } + actions={ actions } + message={ message } + promptUserInputRef={ promptUserInputRef } + /> + ); +} + +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 ( - <div className="jetpack-components-ai-control__container-wrapper"> - { errorComponent } - <div className="jetpack-components-ai-control__container"> - { bannerComponent } - <div - className={ classNames( 'jetpack-components-ai-control__wrapper', { - 'is-transparent': isTransparent, - } ) } - > - <AiStatusIndicator state={ state } /> - - <div className="jetpack-components-ai-control__input-wrapper"> - <PlainText - value={ value } - onChange={ changeHandler } - placeholder={ placeholder } - className="jetpack-components-ai-control__input" - disabled={ loading || disabled } - ref={ promptUserInputRef } - /> - </div> - - { ( ! showAccept || editRequest ) && ( - <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> - { ! loading ? ( - <> - { editRequest && ( - <Button - className="jetpack-components-ai-control__controls-prompt_button" - onClick={ cancelEdit } - variant="secondary" - label={ __( 'Cancel', 'jetpack-ai-client' ) } - > - { showButtonLabels ? ( - __( 'Cancel', 'jetpack-ai-client' ) - ) : ( - <Icon icon={ closeSmall } /> - ) } - </Button> - ) } - - { showRemove && ! editRequest && ! value?.length && onDiscard && ( - <Button - className="jetpack-components-ai-control__controls-prompt_button" - onClick={ discardHandler } - variant="secondary" - label={ __( 'Cancel', 'jetpack-ai-client' ) } - > - { showButtonLabels ? ( - __( 'Cancel', 'jetpack-ai-client' ) - ) : ( - <Icon icon={ closeSmall } /> - ) } - </Button> - ) } - - { value?.length > 0 && ( - <Button - className="jetpack-components-ai-control__controls-prompt_button" - onClick={ sendRequest } - variant="primary" - disabled={ ! value?.length || disabled } - label={ __( 'Send request', 'jetpack-ai-client' ) } - > - { showButtonLabels ? ( - __( 'Generate', 'jetpack-ai-client' ) - ) : ( - <Icon icon={ arrowUp } /> - ) } - </Button> - ) } - </> - ) : ( - <Button - className="jetpack-components-ai-control__controls-prompt_button" - onClick={ onStop } - variant="secondary" - label={ __( 'Stop request', 'jetpack-ai-client' ) } - > - { showButtonLabels ? ( - __( 'Stop', 'jetpack-ai-client' ) - ) : ( - <Icon icon={ closeSmall } /> - ) } - </Button> - ) } - </div> - ) } - - { showAccept && ! editRequest && ( - <div className="jetpack-components-ai-control__controls-prompt_button_wrapper"> - { ( value?.length > 0 || lastValue === null ) && ( - <ButtonGroup> - <Button - className="jetpack-components-ai-control__controls-prompt_button" - label={ __( 'Discard', 'jetpack-ai-client' ) } - onClick={ discardHandler } - tooltipPosition="top" - > - <Icon icon={ trash } /> - </Button> - <Button - className="jetpack-components-ai-control__controls-prompt_button" - label={ __( 'Regenerate', 'jetpack-ai-client' ) } - onClick={ () => onSend?.( value ) } - tooltipPosition="top" - disabled={ ! value?.length || value === null || disabled } - > - <Icon icon={ reusableBlock } /> - </Button> - </ButtonGroup> - ) } - <Button - className="jetpack-components-ai-control__controls-prompt_button" - onClick={ onAccept } - variant="primary" - label={ acceptLabel } - > - { showButtonLabels ? acceptLabel : <Icon icon={ check } /> } - </Button> - </div> - ) } - </div> - { showGuideLine && ! loading && ! editRequest && ( customFooter || <GuidelineMessage /> ) } - </div> - </div> - ); -} - -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 ( - <div className="jetpack-ai-assistant__message"> - { ( severity || icon ) && <Icon icon={ messageIconsMap[ severity ] || icon } /> } - <div className="jetpack-ai-assistant__message-content">{ children }</div> - </div> - ); -} - -/** - * React component to render a guideline message. - * - * @returns {React.ReactElement } - Message component. - */ -export function GuidelineMessage(): React.ReactElement { - return ( - <Message severity={ MESSAGE_SEVERITY_INFO }> - { createInterpolateElement( - __( - 'AI-generated content could be inaccurate or biased. <link>Learn more</link>', - 'jetpack-ai-client' - ), - { - link: <ExternalLink href="https://automattic.com/ai-guidelines" />, - } - ) } - </Message> - ); -} - -/** - * 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 ( - <Message severity={ MESSAGE_SEVERITY_INFO }> - { createInterpolateElement( - sprintf( - // translators: %1$d: number of requests remaining - __( - 'You have %1$d free requests remaining. <link>Upgrade</link> and avoid interruptions', - 'jetpack-ai-client' - ), - requestsRemaining - ), - { - link: <Button variant="link" onClick={ onUpgradeClick } />, - } - ) } - </Message> - ); -} 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 => ( + <div style={ { backgroundColor: 'white' } }> + <Story /> + </div> + ), + ], + 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': <GuidelineMessage />, + 'Error message': <ErrorMessage onTryAgainClick={ action( 'onTryAgainClick' ) } />, + 'Upgrade message': ( + <UpgradeMessage requestsRemaining={ 10 } onUpgradeClick={ action( 'onUpgradeClick' ) } /> + ), + }, + }, + actions: { + control: { + type: 'select', + }, + options: [ 'None', 'Accept button' ], + mapping: { + None: null, + 'Accept button': <Button>Accept</Button>, + }, + }, + error: { + control: { + type: 'select', + }, + options: [ 'None', 'Error notice' ], + mapping: { + None: null, + 'Error notice': ( + <Notice status="error" isDismissible={ true }> + Error message + </Notice> + ), + }, + }, + }, + parameters: { + controls: { + exclude: /on[A-Z].*/, + }, + }, +}; + +const DefaultTemplate = args => { + const [ value, setValue ] = useState( '' ); + + const handleChange = ( newValue: string ) => { + setValue( newValue ); + args?.onChange?.( newValue ); + }; + + return <AIControl { ...args } onChange={ handleChange } value={ args?.value ?? value } />; +}; + +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 => ( + <div style={ { backgroundColor: 'white' } }> + <Story /> + </div> + ), + ], + argTypes: { + state: { + control: { + type: 'select', + }, + options: [ 'init', 'requesting', 'suggesting', 'done', 'error' ], + }, + error: { + control: { + type: 'select', + }, + options: [ 'None', 'Error notice' ], + mapping: { + None: null, + 'Error notice': ( + <Notice status="error" isDismissible={ true }> + Error message + </Notice> + ), + }, + }, + customFooter: { + control: { + type: 'select', + }, + options: [ 'None', 'Guideline message', 'Error message', 'Upgrade message' ], + mapping: { + None: null, + 'Guideline message': <GuidelineMessage />, + 'Error message': <ErrorMessage onTryAgainClick={ action( 'onTryAgainClick' ) } />, + 'Upgrade message': ( + <UpgradeMessage requestsRemaining={ 10 } onUpgradeClick={ action( 'onUpgradeClick' ) } /> + ), + }, + }, + }, + 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 ( + <BlockAIControl + { ...args } + onChange={ handleChange } + onSend={ handleSend } + onStop={ handleStop } + onAccept={ handleAccept } + onDiscard={ handleDiscard } + value={ args?.value ?? value } + /> + ); +}; + +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 => ( + <div style={ { backgroundColor: 'white' } }> + <Story /> + </div> + ), + ], + 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 ( + <ExtensionAIControl + { ...args } + onChange={ handleChange } + onSend={ handleSend } + onStop={ handleStop } + onClose={ handleClose } + onUndo={ handleUndo } + onUpgrade={ handleUpgrade } + value={ args?.value ?? value } + /> + ); +}; + +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 => ( - <div style={ { backgroundColor: 'white' } }> - <Story /> - </div> - ), - ], - 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 <AIControl { ...args } onChange={ handleChange } value={ args?.value ?? value } />; -}; - -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 ( + <div + className={ classNames( + 'jetpack-ai-assistant__message', + `jetpack-ai-assistant__message-severity-${ severity }` + ) } + > + { ( messageIconsMap[ severity ] || icon ) && ( + <Icon icon={ messageIconsMap[ severity ] || icon } /> + ) } + <div className="jetpack-ai-assistant__message-content">{ children }</div> + { showSidebarIcon && ( + <Button className="jetpack-ai-assistant__message-sidebar" onClick={ onSidebarIconClick }> + <Icon size={ 20 } icon={ arrowRight } /> + </Button> + ) } + </div> + ); +} + +/** + * React component to render a guideline message. + * + * @returns {React.ReactElement } - Message component. + */ +export function GuidelineMessage(): React.ReactElement { + return ( + <Message> + <span> + { __( 'AI-generated content could be inaccurate or biased.', 'jetpack-ai-client' ) } + </span> + <ExternalLink href="https://automattic.com/ai-guidelines"> + { __( 'Learn more', 'jetpack-ai-client' ) } + </ExternalLink> + </Message> + ); +} + +/** + * 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 ( + <Message severity={ MESSAGE_SEVERITY_WARNING }> + <span> + { sprintf( + // translators: %1$d: number of requests remaining + __( 'You have %1$d free requests remaining.', 'jetpack-ai-client' ), + requestsRemaining + ) } + </span> + <Button variant="link" onClick={ onUpgradeClick }> + { __( 'Upgrade now', 'jetpack-ai-client' ) } + </Button> + </Message> + ); +} + +/** + * 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 ( + <Message severity={ MESSAGE_SEVERITY_ERROR }> + <span> + { sprintf( + // translators: %1$d: A dynamic error message + __( 'Error: %1$s.', 'jetpack-ai-client' ), + errorMessage + ) } + </span> + <Button variant="link" onClick={ onTryAgainClick }> + { __( 'Try Again', 'jetpack-ai-client' ) } + </Button> + </Message> + ); +} 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 => ( + <div style={ { backgroundColor: 'transparent' } }> + <Story /> + </div> + ), + ], +}; + +const DefaultTemplate = args => { + return <Message { ...args } />; +}; + +const DefaultArgs = { + children: <span>Message</span>, +}; + +export const Default = DefaultTemplate.bind( {} ); +Default.args = DefaultArgs; + +const GuidelineTemplate = args => { + return <GuidelineMessage { ...args } />; +}; + +const GuidelineArgs = {}; + +export const Guideline = GuidelineTemplate.bind( {} ); +Guideline.args = GuidelineArgs; + +const UpgradeTemplate = args => { + return ( + <UpgradeMessage + requestsRemaining={ args.requestsRemaining } + onUpgradeClick={ action( 'onUpgradeClick' ) } + /> + ); +}; + +const UpgradeArgs = { + requestsRemaining: 10, +}; + +export const Upgrade = UpgradeTemplate.bind( {} ); +Upgrade.args = UpgradeArgs; + +const ErrorTemplate = args => { + return <ErrorMessage error={ args.error } onTryAgainClick={ action( 'onTryAgainClick' ) } />; +}; + +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 = ( + <SVG width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <Path + fillRule="evenodd" + clipRule="evenodd" + d="M10 3.95833C6.66328 3.95833 3.95833 6.66327 3.95833 9.99999C3.95833 13.3367 6.66328 16.0417 10 16.0417C13.3367 16.0417 16.0417 13.3367 16.0417 9.99999C16.0417 6.66327 13.3367 3.95833 10 3.95833ZM2.70833 9.99999C2.70833 5.97292 5.97292 2.70833 10 2.70833C14.0271 2.70833 17.2917 5.97292 17.2917 9.99999C17.2917 14.0271 14.0271 17.2917 10 17.2917C5.97292 17.2917 2.70833 14.0271 2.70833 9.99999Z" + /> + <Path d="M10.8333 5.83333H9.16667V10.8333H10.8333V5.83333Z" /> + <Path d="M10.8333 12.5H9.16667V14.1667H10.8333V12.5Z" /> + </SVG> +); + +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 } /> ) } - <AIControl + <BlockAIControl ref={ aiControlRef } disabled={ requireUpgrade || ! connected } value={ attributes.userPrompt } @@ -399,11 +404,12 @@ export default function AIAssistantEdit( { attributes, setAttributes, clientId, acceptLabel={ acceptLabel } showGuideLine={ contentIsLoaded } showRemove={ attributes?.content?.length > 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 ? ( <UpgradeMessage requestsRemaining={ requestsRemaining } diff --git a/projects/plugins/jetpack/extensions/blocks/ai-assistant/extensions/jetpack-contact-form/components/ai-assistant-bar/index.tsx b/projects/plugins/jetpack/extensions/blocks/ai-assistant/extensions/jetpack-contact-form/components/ai-assistant-bar/index.tsx index b18a83cb9f75c..39f3c084b2ac2 100644 --- a/projects/plugins/jetpack/extensions/blocks/ai-assistant/extensions/jetpack-contact-form/components/ai-assistant-bar/index.tsx +++ b/projects/plugins/jetpack/extensions/blocks/ai-assistant/extensions/jetpack-contact-form/components/ai-assistant-bar/index.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useAiContext, AIControl, ERROR_QUOTA_EXCEEDED } from '@automattic/jetpack-ai-client'; +import { useAiContext, BlockAIControl, ERROR_QUOTA_EXCEEDED } from '@automattic/jetpack-ai-client'; import { useAnalytics } from '@automattic/jetpack-shared-extension-utils'; import { serialize } from '@wordpress/blocks'; import { KeyboardShortcuts } from '@wordpress/components'; @@ -19,6 +19,7 @@ import { import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import classNames from 'classnames'; +import React from 'react'; /** * Internal dependencies */ @@ -268,7 +269,7 @@ export default function AiAssistantBar( { > { siteRequireUpgrade && <UpgradePrompt placement="jetpack-form-block" /> } { ! connected && <ConnectPrompt /> } - <AIControl + <BlockAIControl ref={ inputRef } disabled={ siteRequireUpgrade || ! connected } value={ inputValue }