Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AI Client: Separate AIControl UI from block logic and update messages #36967

Merged
merged 17 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

AI Client: Separate AIControl UI from block logic
1 change: 1 addition & 0 deletions projects/js-packages/ai-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* 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;
bannerComponent?: ReactElement;
errorComponent?: ReactElement;
actions?: ReactElement;
message?: ReactElement;
promptUserInputRef?: React.MutableRefObject< HTMLInputElement >;
};

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};

/**
* 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 = noop,
bannerComponent = null,
errorComponent = null,
dhasilva marked this conversation as resolved.
Show resolved Hide resolved
actions = null,
message = null,
promptUserInputRef = null,
}: AIControlProps ): ReactElement {
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={ onChange }
placeholder={ placeholder }
className="jetpack-components-ai-control__input"
disabled={ disabled }
ref={ promptUserInputRef }
/>
</div>
{ actions }
</div>
{ message }
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* 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;
bannerComponent?: ReactElement;
errorComponent?: ReactElement;
};

// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};

const debug = debugFactory( 'jetpack-ai-client:ai-control' );
dhasilva marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we planning to use this in more than one place? Could be a discussion about keeping it in the package or not (not for this PR)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a very good catch. I'd also prefer to have this on the ai-assistant folder instead of the package.
Maybe we can have a call with @lhkowalski to decide where to move this and the other components as a last task?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can have a call with @lhkowalski to decide where to move this and the other components as a last task?

Yes, for sure. Let's do it.

{
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,
dhasilva marked this conversation as resolved.
Show resolved Hide resolved
showRemove = false,
bannerComponent = null,
errorComponent = 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 );
}
},
[ onChange, lastValue, state ]
);

const discardHandler = useCallback( () => {
onDiscard?.();
}, [ onDiscard ] );

const cancelEdit = useCallback( () => {
debug( 'cancelEdit, revert to last value', lastValue );
onChange( lastValue || '' );
setEditRequest( false );
}, [ onChange, 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 }
bannerComponent={ bannerComponent }
errorComponent={ errorComponent }
actions={ actions }
message={ message }
promptUserInputRef={ promptUserInputRef }
/>
);
}

export default forwardRef( BlockAIControl );
Loading
Loading