Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
35 changes: 1 addition & 34 deletions apps/studio/src/components/ai-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Icon, moreVertical, keyboardReturn, reset } from '@wordpress/icons';
import React, { forwardRef, useRef, useEffect, useState } from 'react';
import { ArrowIcon } from 'src/components/arrow-icon';
import { TELEX_HOSTNAME, TELEX_UTM_PARAMS } from 'src/constants';
import useAiIcon from 'src/hooks/use-ai-icon';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { addUrlParams } from 'src/lib/url-utils';
Expand Down Expand Up @@ -57,34 +56,17 @@ const UnforwardedAIInput = (
}: AIInputProps,
inputRef: React.RefObject< HTMLTextAreaElement > | React.RefCallback< HTMLTextAreaElement > | null
) => {
const [ isTyping, setIsTyping ] = useState( false );
const [ thinkingDuration, setThinkingDuration ] = useState<
'short' | 'medium' | 'long' | 'veryLong'
>( 'short' );
const typingTimeout = useRef< NodeJS.Timeout >();
const thinkingTimeout = useRef< NodeJS.Timeout[] >( [] );

const { RiveComponent } = useAiIcon( {
inactive: disabled,
thinking: isAssistantThinking,
typing: isTyping,
} );

useEffect( () => {
if ( ! disabled && inputRef && 'current' in inputRef && inputRef.current ) {
inputRef.current?.focus();
}
}, [ disabled, inputRef ] );

useEffect(
() => () => {
if ( typingTimeout.current ) {
clearTimeout( typingTimeout.current );
}
},
[]
);

const handleInput = ( e: React.ChangeEvent< HTMLTextAreaElement > ) => {
setInput( e.target.value );

Expand Down Expand Up @@ -126,21 +108,10 @@ const UnforwardedAIInput = (
// Allow Shift + Enter to create a new line
return;
} else {
setIsTyping( true );
handleKeyDown( e );
}
};

const handleKeyUpWrapper = () => {
if ( typingTimeout.current ) {
clearTimeout( typingTimeout.current );
}

typingTimeout.current = setTimeout( () => {
setIsTyping( false );
}, 400 );
};

useEffect( () => {
function clearThinkingTimeouts() {
thinkingTimeout.current.forEach( clearTimeout );
Expand Down Expand Up @@ -223,22 +194,18 @@ const UnforwardedAIInput = (
}`
) }
>
<div className={ `flex items-center h-12 ${ disabled && 'opacity-20 grayscale' }` }>
<RiveComponent aria-hidden="true" style={ { width: 48, height: 48 } } />
</div>
<textarea
ref={ inputRef }
disabled={ disabled }
placeholder={ getPlaceholderText() }
className={ cx(
`w-full px-1 py-3.5 rounded-sm border-none bg-transparent resize-none focus:outline-none assistant-textarea ${
`w-full px-4 py-3.5 rounded-sm border-none bg-transparent resize-none focus:outline-none assistant-textarea ${
disabled ? 'cursor-not-allowed opacity-30' : ''
}`
) }
value={ input }
onChange={ handleInput }
onKeyDown={ handleKeyDownWrapper }
onKeyUp={ handleKeyUpWrapper }
rows={ 1 }
data-testid="ai-input-textarea"
/>
Expand Down
117 changes: 85 additions & 32 deletions apps/studio/src/components/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import Anchor from 'src/components/assistant-anchor';
import createCodeComponent from 'src/components/assistant-code-block';
import { FeedbackThanks } from 'src/components/chat-rating';
import { copy, Icon } from '@wordpress/icons';
import { useCallback, useState } from 'react';
import Button from 'src/components/button';
import { ChatRating } from 'src/components/chat-rating';
import { cx } from 'src/lib/cx';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { Message } from 'src/stores/chat-slice';

export interface ChatMessageProps {
Expand All @@ -17,8 +21,8 @@ export interface ChatMessageProps {
message: Message;
isUnauthenticated?: boolean;
failedMessage?: boolean;
feedbackReceived?: boolean;
instanceId: string;
onRate?: ( ratingValue: number ) => void;
}

export const MarkDownWithCode = ( {
Expand Down Expand Up @@ -50,53 +54,102 @@ export const MarkDownWithCode = ( {
</Markdown>
</div>
);

const CopyButton = ( { text }: { text: string } ) => {
const [ showCopied, setShowCopied ] = useState( false );
const onClick = useCallback( () => {
void getIpcApi().copyText( text );
setShowCopied( true );
setTimeout( () => setShowCopied( false ), 2000 );
}, [ text ] );

return (
<Button
variant="icon"
className={ cx(
'text-a8c-gray-70 hover:!text-a8c-blue-50',
showCopied && '!text-a8c-blue-50'
) }
onClick={ onClick }
tooltipText={ __( 'Copy to clipboard' ) }
>
<Icon size={ 18 } icon={ copy } />
</Button>
);
};

const MessageActions = ( {
content,
isAssistant,
onRate,
}: {
content: string;
isAssistant: boolean;
onRate?: ( ratingValue: number ) => void;
} ) => (
<div className="flex items-center gap-1 mt-2 pl-3 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={ content } />
{ isAssistant && <ChatRating onRate={ onRate } /> }
</div>
);

export const ChatMessage = forwardRef< HTMLDivElement, ChatMessageProps >(
( { id, message, className, siteId, children, isUnauthenticated, instanceId }, ref ) => {
( { id, message, className, siteId, children, isUnauthenticated, instanceId, onRate }, ref ) => {
const isString = typeof children === 'string';

const isUser = message.role === 'user';

return (
<>
<div ref={ ref } className="h-4" />
<div
className={ cx(
'flex',
isUnauthenticated || message.role !== 'user'
'group flex',
isUnauthenticated || ! isUser
? 'justify-start ltr:md:mr-24 rtl:md:ml-24'
: 'justify-end ltr:md:ml-24 rtl:md:mr-24',
className
) }
>
<div
id={ id }
role="group"
data-testid="chat-message"
aria-labelledby={ id }
className={ cx(
'inline-block p-3 rounded border overflow-x-auto overflow-y-hidden select-text',
isUnauthenticated ? 'lg:max-w-[90%]' : 'lg:max-w-[70%]',
message.failedMessage
? 'border-[#FACFD2] bg-[#F7EBEC]'
: message.role === 'user'
? 'bg-white'
: 'bg-white/45',
! message.failedMessage && 'border-gray-300'
) }
>
<div className="relative">
<span className="sr-only">
{ message.role === 'user' ? __( 'Your message' ) : __( 'Studio Assistant' ) },
</span>
</div>
{ typeof children === 'string' ? (
<>
<div className={ cx(
'inline-flex flex-col',
isUnauthenticated ? 'lg:max-w-[90%]' : 'lg:max-w-[70%]',
) }>
<div
id={ id }
role="group"
data-testid="chat-message"
aria-labelledby={ id }
className={ cx(
'p-3 overflow-x-auto overflow-y-hidden select-text',
! isUser && 'pb-0',
message.failedMessage
? 'rounded border border-[#FACFD2] bg-[#F7EBEC]'
: isUser && 'rounded-xl bg-a8c-gray-100'
) }
>
<div className="relative">
<span className="sr-only">
{ isUser ? __( 'Your message' ) : __( 'Studio Assistant' ) },
</span>
</div>
{ isString ? (
<MarkDownWithCode
message={ message }
siteId={ siteId }
instanceId={ instanceId }
content={ children }
/>
{ message.feedbackReceived && <FeedbackThanks /> }
</>
) : (
children
) : (
children
) }
</div>
{ message.content && (
<MessageActions
content={ message.content }
isAssistant={ ! isUser }
onRate={ onRate }
/>
) }
</div>
</div>
Expand Down
94 changes: 56 additions & 38 deletions apps/studio/src/components/chat-rating.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,77 @@
import { __ } from '@wordpress/i18n';
import { thumbsUp, thumbsDown, Icon } from '@wordpress/icons';
import { Icon, thumbsUp, thumbsDown } from '@wordpress/icons';
import { SVG, Path } from '@wordpress/primitives';
import { useState } from 'react';
import Button from 'src/components/button';
import { useAuth } from 'src/hooks/use-auth';
import { useAppDispatch } from 'src/stores';
import { chatThunks } from 'src/stores/chat-slice';
import { cx } from 'src/lib/cx';

const thumbsUpFilled = (
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<Path d="m3 12 1 8h1.5l-1-8H3Zm15.8-2h-4.4l.8-3.6c.3-1.3-.7-2.4-1.9-2.4h-.2c-.6 0-1.2.3-1.6.8l-5 6.6c-.3.4-.4.8-.4 1.2v.2l.7 5.4v.2c.2.9 1 1.5 1.9 1.5h8.2c.9 0 1.7-.6 1.9-1.4l1.8-6c.4-1.3-.6-2.6-1.9-2.6Z" />
</SVG>
);

const thumbsDownFilled = (
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<Path d="M19.8 4h-1.5l1 8h1.5l-1-8ZM17 5.8c-.1-1-1-1.8-2-1.8H6.8c-.9 0-1.7.6-1.9 1.4l-1.8 6C2.7 12.7 3.7 14 5 14h4.4l-.8 3.6c-.3 1.3.7 2.4 1.9 2.4h.2c.6 0 1.2-.3 1.6-.8l5-6.6c.3-.4.5-.9.4-1.5L17 5.7Z" />
</SVG>
);

interface ChatRatingProps {
instanceId: string;
messageApiId: number;
feedbackReceived: boolean;
onRate?: ( ratingValue: number ) => void;
className?: string;
}

export const FeedbackThanks = () => {
return (
<div className="text-a8c-gray-70 italic text-xs flex justify-end mt-4">
{ __( 'Thanks for the feedback!' ) }
</div>
);
};
export const ChatRating = ( { onRate, className }: ChatRatingProps ) => {
const [ selectedRating, setSelectedRating ] = useState< number | null >( null );

export const ChatRating = ( { messageApiId, feedbackReceived, instanceId }: ChatRatingProps ) => {
const { client } = useAuth();
const dispatch = useAppDispatch();
const handleRatingClick = async ( feedback: number ) => {
if ( ! client ) {
return;
const handleClick = ( value: number ) => {
if ( selectedRating === value ) {
setSelectedRating( null );
} else {
setSelectedRating( value );
onRate?.( value );
}

void dispatch(
chatThunks.sendFeedback( { client, messageApiId, ratingValue: feedback, instanceId } )
);
};

return feedbackReceived ? (
<FeedbackThanks />
) : (
<div className="flex flex-col mt-4 items-start gap-3">
<div className="flex items-center gap-3">
<span className="text-a8c-gray-70 text-xs">{ __( 'Was this helpful?' ) }</span>
const isVisible = ( value: number ) => selectedRating === null || selectedRating === value;

return (
<div className={ cx( 'flex items-center gap-1', className ) }>
<div
className="flex overflow-hidden transition-all duration-200 ease-in-out"
style={ { maxWidth: isVisible( 1 ) ? 36 : 0, opacity: isVisible( 1 ) ? 1 : 0 } }
>
<Button
variant="icon"
className="text-a8c-green-50 hover:!text-a8c-green-50 flex items-center gap-1"
onClick={ () => handleRatingClick( 1 ) }
className={ cx(
'transition-colors duration-200',
selectedRating === 1
? 'text-a8c-green-50'
: 'text-a8c-gray-70 hover:!text-a8c-green-50'
) }
onClick={ () => handleClick( 1 ) }
tooltipText={ __( 'Helpful' ) }
>
<Icon size={ 18 } icon={ thumbsUp } />
<span className="text-xs">{ __( 'Yes' ) }</span>
<Icon size={ 18 } icon={ selectedRating === 1 ? thumbsUpFilled : thumbsUp } />
</Button>
</div>
<div
className="flex overflow-hidden transition-all duration-200 ease-in-out"
style={ { maxWidth: isVisible( 0 ) ? 36 : 0, opacity: isVisible( 0 ) ? 1 : 0 } }
>
<Button
variant="icon"
className="text-a8c-red-50 hover:!text-a8c-red-50 flex items-center gap-1"
onClick={ () => handleRatingClick( 0 ) }
className={ cx(
'transition-colors duration-200',
selectedRating === 0
? 'text-a8c-red-50'
: 'text-a8c-gray-70 hover:!text-a8c-red-50'
) }
onClick={ () => handleClick( 0 ) }
tooltipText={ __( 'Unhelpful' ) }
>
<Icon size={ 18 } icon={ thumbsDown } />
<span className="text-xs">{ __( 'No' ) }</span>
<Icon size={ 18 } icon={ selectedRating === 0 ? thumbsDownFilled : thumbsDown } />
</Button>
</div>
</div>
Expand Down
Loading