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

Refactor ChatProvider into a Redux store #826

Merged
merged 45 commits into from
Jan 30, 2025
Merged
Changes from 34 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
60bea5e
Allow non-relative imports starting with src/ or vendor/
fredrikekelund Jan 21, 2025
30aac87
Fix lint errors
fredrikekelund Jan 21, 2025
2b0fd1a
Small fix
fredrikekelund Jan 21, 2025
3c87ebe
Merge branch 'trunk' into f26d/absolute-imports
fredrikekelund Jan 21, 2025
ca8bc22
WIP
fredrikekelund Jan 21, 2025
4dd8a3c
Tweaks
fredrikekelund Jan 20, 2025
afcfb2f
WIP
fredrikekelund Jan 21, 2025
a02cb28
WIP
fredrikekelund Jan 21, 2025
f801f9a
Merge branch 'trunk' into f26d/redux-chat-slice
fredrikekelund Jan 22, 2025
b9ebf6d
WIP
fredrikekelund Jan 22, 2025
a55bd4d
Send feedback from Redux
fredrikekelund Jan 22, 2025
b672d12
Completely remove useAssistant hook
fredrikekelund Jan 22, 2025
9b4bfe3
Small fixes
fredrikekelund Jan 22, 2025
bcaea76
Optimize rendering
fredrikekelund Jan 22, 2025
6076d79
Use a site dict for plugin and theme lists
fredrikekelund Jan 22, 2025
1bd6a3b
Use middleware to save to localStorage
fredrikekelund Jan 22, 2025
72437ce
Store prompt usage
fredrikekelund Jan 22, 2025
daeebd3
Support import aliases in Jest
fredrikekelund Jan 22, 2025
57674c3
Fix test
fredrikekelund Jan 22, 2025
eef5f8e
Fix some tests
fredrikekelund Jan 23, 2025
d4b9700
Store messages, chatId and loading state by instanceId instead of siteId
fredrikekelund Jan 23, 2025
48bbe9b
Wrap submitPrompt in useCallback
fredrikekelund Jan 23, 2025
f8a9949
Small fixes
fredrikekelund Jan 23, 2025
f19284e
Rename chatId to chatApiId
fredrikekelund Jan 23, 2025
691a1ca
Fix issue with duplicate messages in API payload
fredrikekelund Jan 23, 2025
1b5e29f
Fix
fredrikekelund Jan 23, 2025
06be461
Fix retry logic
fredrikekelund Jan 23, 2025
5222faa
Merge branch 'trunk' into f26d/redux-chat-slice
fredrikekelund Jan 23, 2025
b305c1d
Don't change tsconfig
fredrikekelund Jan 23, 2025
4ce8938
Redux reducer tests
fredrikekelund Jan 24, 2025
029c158
More tests
fredrikekelund Jan 24, 2025
f1c1b19
Remove unused isSiteLoadedDict state
fredrikekelund Jan 24, 2025
0522a75
Fix
fredrikekelund Jan 24, 2025
4ef2eef
Merge branch 'trunk' into f26d/redux-chat-slice
fredrikekelund Jan 29, 2025
32c332f
Address review feedback
fredrikekelund Jan 29, 2025
c8730bf
Merge branch 'trunk' into f26d/redux-chat-slice
fredrikekelund Jan 29, 2025
fb7edf2
Export actions and selectors under predefined namespace
fredrikekelund Jan 29, 2025
7bf88ab
Reload values from localStorage when reading initial state
fredrikekelund Jan 29, 2025
4e9d661
Move the reset state action to a test util file
fredrikekelund Jan 29, 2025
3c853d6
Fix updateMessage action
fredrikekelund Jan 30, 2025
f8b64a5
Fix tests
fredrikekelund Jan 30, 2025
8e23de1
Update @reduxjs/toolkit to latest version
fredrikekelund Jan 30, 2025
3de62b0
Fix warnings in content-tab-assistant.test.tsx
fredrikekelund Jan 30, 2025
68714f0
Test `isRetry: true` calls to fetchAssistantThunk
fredrikekelund Jan 30, 2025
bee5f84
Export thunks under chatThunks namespace
fredrikekelund Jan 30, 2025
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
123 changes: 114 additions & 9 deletions package-lock.json

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

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -108,6 +108,7 @@
"@php-wasm/node": "^0.9.44",
"@php-wasm/scopes": "^0.9.44",
"@php-wasm/universal": "^0.9.44",
"@reduxjs/toolkit": "^2.5.0",
Copy link
Member

Choose a reason for hiding this comment

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

A new version 2.5.1 was released 3 days ago. We could update it.

Suggested change
"@reduxjs/toolkit": "^2.5.0",
"@reduxjs/toolkit": "^2.5.1",

"@rive-app/react-canvas": "^4.12.0",
"@sentry/electron": "^4.24.0",
"@sentry/webpack-plugin": "^2.16.1",
@@ -130,6 +131,7 @@
"fs-extra": "11.1.1",
"hpagent": "1.2.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"remark-gfm": "^4.0.0",
"semver": "^7.6.0",
"shell-quote": "^1.8.1",
2 changes: 1 addition & 1 deletion src/components/ai-clear-history-reminder.tsx
Original file line number Diff line number Diff line change
@@ -2,8 +2,8 @@ import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useCallback, useState, useEffect, useRef } from 'react';
import { CLEAR_HISTORY_REMINDER_TIME } from '../constants';
import { Message as MessageType } from '../hooks/use-assistant';
import { getIpcApi } from '../lib/get-ipc-api';
import { Message as MessageType } from '../stores/chat-slice';
import Button from './button';

function shouldShowReminder( lastMessage?: MessageType ) {
26 changes: 8 additions & 18 deletions src/components/assistant-code-block.tsx
Original file line number Diff line number Diff line change
@@ -4,30 +4,29 @@ import { Icon, archive, edit, preformatted } from '@wordpress/icons';
import { useCallback, useEffect, useState } from 'react';
import { ExtraProps } from 'react-markdown';
import stripAnsi from 'strip-ansi';
import { Message as MessageType } from '../hooks/use-assistant';
import { useExecuteWPCLI } from '../hooks/use-execute-cli';
import { useFeatureFlags } from '../hooks/use-feature-flags';
import { useIsValidWpCliInline } from '../hooks/use-is-valid-wp-cli-inline';
import { useSiteDetails } from '../hooks/use-site-details';
import { cx } from '../lib/cx';
import { getIpcApi } from '../lib/get-ipc-api';
import { Message as MessageType } from '../stores/chat-slice';
import Button from './button';
import { ChatMessageProps } from './chat-message';
import { CopyTextButton } from './copy-text-button';
import { ExecuteIcon } from './icons/execute';

type ContextProps = Pick< MessageType, 'blocks' > &
Pick< ChatMessageProps, 'updateMessage' | 'siteId' > & { messageId?: number };
Pick< ChatMessageProps, 'siteId' > & { messageId?: number };

type CodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps;
export type CodeBlockProps = JSX.IntrinsicElements[ 'code' ] & ExtraProps;

export default function createCodeComponent( contextProps: ContextProps ) {
return ( props: CodeBlockProps ) => <CodeBlock { ...contextProps } { ...props } />;
}

const LanguageBlock = ( props: ContextProps & CodeBlockProps ) => {
const { children, className, node, blocks, updateMessage, siteId, messageId, ...htmlAttributes } =
props;
const { children, className, node, blocks, siteId, messageId, ...htmlAttributes } = props;
const content = String( children ).trim();
const isValidWpCliCommand = useIsValidWpCliInline( content );
const {
@@ -39,7 +38,7 @@ const LanguageBlock = ( props: ContextProps & CodeBlockProps ) => {
setCliOutput,
setCliStatus,
setCliTime,
} = useExecuteWPCLI( content, siteId, updateMessage, messageId );
} = useExecuteWPCLI( content, siteId, messageId );

useEffect( () => {
if ( blocks ) {
@@ -126,17 +125,8 @@ const LanguageBlock = ( props: ContextProps & CodeBlockProps ) => {
};

function FileBlock( props: ContextProps & CodeBlockProps & { isDirectory?: boolean } ) {
const {
children,
className,
node,
blocks,
updateMessage,
siteId,
messageId,
isDirectory,
...htmlAttributes
} = props;
const { children, className, node, blocks, siteId, messageId, isDirectory, ...htmlAttributes } =
props;
const content = String( children ).trim();
const [ filePath, setFilePath ] = useState( '' );

@@ -184,7 +174,7 @@ function FileBlock( props: ContextProps & CodeBlockProps & { isDirectory?: boole
function CodeBlock( props: ContextProps & CodeBlockProps ) {
const { children, className } = props;
const content = String( children ).trim();
const { node, blocks, updateMessage, siteId, messageId, ...htmlAttributes } = props;
const { node, blocks, siteId, messageId, ...htmlAttributes } = props;

const isFilePath = ( content: string ) => {
const fileExtensions = [
27 changes: 3 additions & 24 deletions src/components/chat-message.tsx
Original file line number Diff line number Diff line change
@@ -2,8 +2,8 @@ import { __ } from '@wordpress/i18n';
import { forwardRef } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Message } from '../hooks/use-assistant';
import { cx } from '../lib/cx';
import { Message } from '../stores/chat-slice';
import Anchor from './assistant-anchor';
import createCodeComponent from './assistant-code-block';
import { FeedbackThanks } from './chat-rating';
@@ -14,34 +14,19 @@ export interface ChatMessageProps {
className?: string;
siteId?: string;
message: Message;
updateMessage?: (
id: number,
content: string,
output: string,
status: 'success' | 'error',
time: string
) => void;
isUnauthenticated?: boolean;
failedMessage?: boolean;
feedbackReceived?: boolean;
}

export const MarkDownWithCode = ( {
message,
updateMessage,
siteId,
content,
}: {
siteId?: string;
content: string;
message: Message;
updateMessage?: (
id: number,
content: string,
output: string,
status: 'success' | 'error',
time: string
) => void;
} ) => (
<div className="assistant-markdown">
<Markdown
@@ -51,7 +36,6 @@ export const MarkDownWithCode = ( {
blocks: message?.blocks,
messageId: message.id,
siteId,
updateMessage,
} ),
img: () => null,
} }
@@ -62,7 +46,7 @@ export const MarkDownWithCode = ( {
</div>
);
export const ChatMessage = forwardRef< HTMLDivElement, ChatMessageProps >(
( { id, message, className, siteId, updateMessage, children, isUnauthenticated }, ref ) => {
( { id, message, className, siteId, children, isUnauthenticated }, ref ) => {
return (
<>
<div ref={ ref } className="h-4" />
@@ -98,12 +82,7 @@ export const ChatMessage = forwardRef< HTMLDivElement, ChatMessageProps >(
</div>
{ typeof children === 'string' ? (
<>
<MarkDownWithCode
message={ message }
updateMessage={ updateMessage }
siteId={ siteId }
content={ children }
/>
<MarkDownWithCode message={ message } siteId={ siteId } content={ children } />
{ message.feedbackReceived && <FeedbackThanks /> }
</>
) : (
23 changes: 13 additions & 10 deletions src/components/chat-rating.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { __ } from '@wordpress/i18n';
import { thumbsUp, thumbsDown, Icon } from '@wordpress/icons';
import { useAssistant } from '../hooks/use-assistant';
import { useDispatch } from 'react-redux';
import { useAuth } from 'src/hooks/use-auth';
import { AppDispatch } from 'src/stores';
import { sendFeedbackThunk } from 'src/stores/chat-slice';
import Button from './button';

interface ChatRatingProps {
instanceId: string;
messageApiId: number;
feedbackReceived: boolean;
markMessageAsFeedbackReceived: ReturnType<
typeof useAssistant
>[ 'markMessageAsFeedbackReceived' ];
className?: string;
}

@@ -20,13 +21,15 @@ export const FeedbackThanks = () => {
);
};

export const ChatRating = ( {
messageApiId,
markMessageAsFeedbackReceived,
feedbackReceived,
}: ChatRatingProps ) => {
export const ChatRating = ( { messageApiId, feedbackReceived, instanceId }: ChatRatingProps ) => {
const { client } = useAuth();
const dispatch = useDispatch< AppDispatch >();
const handleRatingClick = async ( feedback: number ) => {
markMessageAsFeedbackReceived( messageApiId, feedback );
if ( ! client ) {
return;
}

dispatch( sendFeedbackThunk( { client, messageApiId, ratingValue: feedback, instanceId } ) );
};

return feedbackReceived ? (
185 changes: 69 additions & 116 deletions src/components/content-tab-assistant.tsx
Original file line number Diff line number Diff line change
@@ -7,12 +7,25 @@ import { __, _n, sprintf } from '@wordpress/i18n';
import { Icon, external } from '@wordpress/icons';
import { useI18n } from '@wordpress/react-i18n';
import React, { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useThemeDetails } from 'src/hooks/use-theme-details';
import { cx } from 'src/lib/cx';
import { RootState, AppDispatch } from 'src/stores';
import {
setMessages,
fetchAssistantThunk,
generateMessage,
selectChatInput,
selectIsLoading,
selectMessages,
setChatInput,
updateFromSiteThunk,
updateFromTheme,
selectChatApiId,
Message as MessageType,
} from 'src/stores/chat-slice';
import { AI_GUIDELINES_URL, LIMIT_OF_PROMPTS_PER_USER } from '../constants';
import { useAssistant, Message as MessageType } from '../hooks/use-assistant';
import { useAssistantApi } from '../hooks/use-assistant-api';
import { useAuth } from '../hooks/use-auth';
import { useChatContext } from '../hooks/use-chat-context';
import { useOffline } from '../hooks/use-offline';
import { usePromptUsage } from '../hooks/use-prompt-usage';
import { useWelcomeMessages } from '../hooks/use-welcome-messages';
@@ -95,34 +108,22 @@ const OfflineModeView = () => {
);
};

type OnUpdateMessageType = (
id: number,
codeBlockContent: string,
cliOutput: string,
cliStatus: 'success' | 'error',
cliTime: string
) => void;

interface AuthenticatedViewProps {
messages: MessageType[];
instanceId: string;
isAssistantThinking: boolean;
updateMessage: OnUpdateMessageType;
siteId: string;
submitPrompt: ( messageToSend: string, isRetry?: boolean ) => void;
markMessageAsFeedbackReceived: ReturnType<
typeof useAssistant
>[ 'markMessageAsFeedbackReceived' ];
wrapperRef: React.RefObject< HTMLDivElement >;
}

const AuthenticatedView = memo(
( {
messages,
instanceId,
isAssistantThinking,
updateMessage,
siteId,
submitPrompt,
markMessageAsFeedbackReceived,
wrapperRef,
}: AuthenticatedViewProps ) => {
const lastMessageRef = useRef< HTMLDivElement >( null );
@@ -189,34 +190,27 @@ const AuthenticatedView = memo(
const RenderMessage = useCallback(
( { message }: { message: MessageType } ) => (
<>
<ChatMessage
id={ `message-chat-${ message.id }` }
message={ message }
siteId={ siteId }
updateMessage={ updateMessage }
>
<ChatMessage id={ `message-chat-${ message.id }` } message={ message } siteId={ siteId }>
{ message.content }
</ChatMessage>
{ message.failedMessage && (
<ErrorNotice submitPrompt={ submitPrompt } messageContent={ message.content } />
) }
</>
),
[ submitPrompt, siteId, updateMessage ]
[ submitPrompt, siteId ]
);

const RenderLastMessage = useCallback(
( {
showThinking,
siteId,
updateMessage,
message,
children,
}: {
message: MessageType;
showThinking: boolean;
siteId: string;
updateMessage: OnUpdateMessageType;
children: React.ReactNode;
} ) => {
const thinkingAnimation = {
@@ -236,7 +230,6 @@ const AuthenticatedView = memo(
id={ `message-chat-${ message.id }` }
message={ message }
siteId={ siteId }
updateMessage={ updateMessage }
>
<AnimatePresence mode="wait">
{ showThinking ? (
@@ -261,7 +254,6 @@ const AuthenticatedView = memo(
<MarkDownWithCode
message={ message }
siteId={ siteId }
updateMessage={ updateMessage }
content={ message.content }
/>
{ children }
@@ -286,15 +278,14 @@ const AuthenticatedView = memo(
{ showLastMessage && (
<RenderLastMessage
siteId={ siteId }
updateMessage={ updateMessage }
message={ lastMessage }
showThinking={ showThinking }
>
<div className="flex justify-end">
{ !! lastMessage.messageApiId && (
<ChatRating
instanceId={ instanceId }
messageApiId={ lastMessage.messageApiId }
markMessageAsFeedbackReceived={ markMessageAsFeedbackReceived }
feedbackReceived={ !! lastMessage.feedbackReceived }
/>
) }
@@ -352,104 +343,59 @@ const UnauthenticatedView = ( { onAuthenticate }: { onAuthenticate: () => void }
export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps ) {
const inputRef = useRef< HTMLTextAreaElement >( null );
const wrapperRef = useRef< HTMLDivElement >( null );
const chatContext = useChatContext();
const { isAuthenticated, authenticate, user } = useAuth();
const dispatch = useDispatch< AppDispatch >();
const chatInput = useSelector( ( state: RootState ) =>
selectChatInput( state, selectedSite.id )
);
const { isAuthenticated, authenticate, user, client } = useAuth();
const instanceId = user?.id ? `${ user.id }_${ selectedSite.id }` : selectedSite.id;
const {
messages,
addMessage,
clearMessages,
updateMessage,
markMessageAsFailed,
chatId,
markMessageAsFeedbackReceived,
} = useAssistant( instanceId );
const chatApiId = useSelector( ( state: RootState ) => selectChatApiId( state, instanceId ) );
const messages = useSelector( ( state: RootState ) => selectMessages( state, instanceId ) );
const isAssistantThinking = useSelector( ( state: RootState ) =>
selectIsLoading( state, instanceId )
);
const { userCanSendMessage } = usePromptUsage();
const { fetchAssistant, isLoading: isAssistantThinking } = useAssistantApi( selectedSite.id );
const { messages: welcomeMessages, examplePrompts } = useWelcomeMessages();
const [ currentInput, setCurrentInput ] = useState( '' );
const isOffline = useOffline();
const { __ } = useI18n();
const lastMessage = messages.length === 0 ? undefined : messages[ messages.length - 1 ];
const hasFailedMessage = messages.some( ( msg ) => msg.failedMessage );

// Restore prompt input when site changes
const { selectedThemeDetails: themeDetails } = useThemeDetails();

useEffect( () => {
setCurrentInput( chatContext.getChatInput( selectedSite.id ) );
}, [ selectedSite.id, chatContext ] );

// Save prompt input when it changes
const setInput = useCallback(
( input: string ) => {
chatContext.saveChatInput( input, selectedSite.id );
setCurrentInput( input );
},
[ selectedSite.id, chatContext ]
);
dispatch( updateFromSiteThunk( { site: selectedSite } ) );
}, [ dispatch, selectedSite ] );

useEffect( () => {
if ( themeDetails ) {
dispatch( updateFromTheme( themeDetails ) );
}
}, [ dispatch, themeDetails ] );

const submitPrompt = useCallback(
async ( chatMessage: string, isRetry?: boolean ) => {
if ( ! chatMessage ) {
( chatMessage: string, isRetry?: boolean ) => {
if ( ! chatMessage || ! client ) {
return;
}
let messageId;
if ( chatMessage.trim() ) {
if ( isRetry ) {
// If retrying, find the message ID with failedMessage flag
const failedMessage = messages.find(
( msg ) => msg.failedMessage && msg.content === chatMessage
);
if ( failedMessage ) {
messageId = failedMessage.id;
if ( typeof messageId !== 'undefined' ) {
markMessageAsFailed( messageId, false );
}
}
} else {
messageId = addMessage( chatMessage, 'user', chatId ); // Get the new message ID
setInput( '' );
}
try {
const {
message,
chatId: fetchedChatId,
messageApiId,
} = await fetchAssistant(
chatId,
[
...messages,
{ id: messageId, content: chatMessage, role: 'user', createdAt: Date.now() },
],
chatContext
);
if ( message ) {
addMessage( message, 'assistant', chatId ?? fetchedChatId, messageApiId );
}
} catch ( error ) {
if ( typeof messageId !== 'undefined' ) {
markMessageAsFailed( messageId, true );
}
}

if ( ! isRetry ) {
dispatch( setChatInput( { siteId: selectedSite.id, input: '' } ) );
}
},
[ addMessage, chatId, chatContext, fetchAssistant, markMessageAsFailed, messages, setInput ]
);

// Submit prompt input when the user clicks the send button
const handleSend = useCallback( () => {
submitPrompt( inputRef.current?.value ?? '' );
setInput( '' );
}, [ submitPrompt, setInput ] );
const newMessageId = isRetry ? messages.length - 1 : messages.length;
const message = generateMessage( chatMessage, 'user', newMessageId, chatApiId );

const handleKeyDown = ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => {
if ( e.key === 'Enter' ) {
handleSend();
}
};
dispatch(
fetchAssistantThunk( { client, instanceId, isRetry, message, siteId: selectedSite.id } )
);
},
[ client, dispatch, instanceId, selectedSite.id, messages, chatApiId ]
);

const clearConversation = () => {
setInput( '' );
clearMessages();
dispatch( setChatInput( { siteId: selectedSite.id, input: '' } ) );
dispatch( setMessages( { instanceId, messages: [] } ) );
};

// We should render only one notice at a time in the bottom area
@@ -494,8 +440,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
<AuthenticatedView
messages={ messages }
isAssistantThinking={ isAssistantThinking }
updateMessage={ updateMessage }
markMessageAsFeedbackReceived={ markMessageAsFeedbackReceived }
instanceId={ instanceId }
siteId={ selectedSite.id }
submitPrompt={ submitPrompt }
wrapperRef={ wrapperRef }
@@ -513,10 +458,18 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
<AIInput
ref={ inputRef }
disabled={ disabled }
input={ currentInput }
setInput={ setInput }
handleSend={ handleSend }
handleKeyDown={ handleKeyDown }
input={ chatInput }
setInput={ ( input ) => {
dispatch( setChatInput( { siteId: selectedSite.id, input } ) );
Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking and:

  1. I don't see benefits to store temporary value in global store. We can achieve the same with storing in inside this component's state.
  2. Every keydown triggers a lot of redundant store checks, even every prodicate inside listenerMiddleware.startListening is triggered.

Maybe I am missing something, but so far it looks like overkill.
WDYT?

UPDATE: I see, we store values for different chats... sad that we trigger store so many times during typing, but looks like it's optimal option. Unless, we consider saving it in localStorage, it's typical UX approach, to avoid losing data in case of some errors or reloads of window. But it's more as an improvement. So I think it's better to leave it as is.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Storing this state in the Redux store helps maintain the bug fixes introduced in #788. Sounds like we're on the same page already, but to respond to your points:

I don't see benefits to store temporary value in global store. We can achieve the same with storing in inside this component's state.

The component state is reset when navigating between tabs, and will soon (see https://github.com/Automattic/dotcom-forge/issues/10219) be reset when navigating between sites, too.

Every keydown triggers a lot of redundant store checks, even every prodicate inside listenerMiddleware.startListening is triggered.

The middleware predicates are very cheap. Worth keeping in mind.

} }
handleSend={ () => {
submitPrompt( inputRef.current?.value ?? '' );
} }
handleKeyDown={ ( event ) => {
if ( event.key === 'Enter' ) {
submitPrompt( inputRef.current?.value ?? '' );
}
} }
clearConversation={ clearConversation }
isAssistantThinking={ isAssistantThinking }
/>
47 changes: 24 additions & 23 deletions src/components/root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Provider as ReduxProvider } from 'react-redux';
import store from 'src/stores';
import { SyncSitesProvider } from '../hooks/sync-sites/sync-sites-context';
import { ChatProvider } from '../hooks/use-chat-context';
import { InstalledAppsProvider } from '../hooks/use-check-installed-apps';
import { ContentTabsProvider } from '../hooks/use-content-tabs';
import { FeatureFlagsProvider } from '../hooks/use-feature-flags';
@@ -20,35 +21,35 @@ const Root = () => {
return (
<ErrorBoundary>
<CrashTester />
<I18nDataProvider>
<AuthProvider>
<SnapshotProvider>
<SiteDetailsProvider>
<FeatureFlagsProvider>
<DemoSiteUpdateProvider>
<ThemeDetailsProvider>
<InstalledAppsProvider>
<OnboardingProvider>
<PromptUsageProvider>
<ChatProvider>
<ReduxProvider store={ store }>
<I18nDataProvider>
<AuthProvider>
<SnapshotProvider>
<SiteDetailsProvider>
<FeatureFlagsProvider>
<DemoSiteUpdateProvider>
<ThemeDetailsProvider>
<InstalledAppsProvider>
<OnboardingProvider>
<PromptUsageProvider>
<ImportExportProvider>
<ContentTabsProvider>
<SyncSitesProvider>
<App />
</SyncSitesProvider>
</ContentTabsProvider>
</ImportExportProvider>
</ChatProvider>
</PromptUsageProvider>
</OnboardingProvider>
</InstalledAppsProvider>
</ThemeDetailsProvider>
</DemoSiteUpdateProvider>
</FeatureFlagsProvider>
</SiteDetailsProvider>
</SnapshotProvider>
</AuthProvider>
</I18nDataProvider>
</PromptUsageProvider>
</OnboardingProvider>
</InstalledAppsProvider>
</ThemeDetailsProvider>
</DemoSiteUpdateProvider>
</FeatureFlagsProvider>
</SiteDetailsProvider>
</SnapshotProvider>
</AuthProvider>
</I18nDataProvider>
</ReduxProvider>
</ErrorBoundary>
);
};
2 changes: 1 addition & 1 deletion src/components/tests/ai-clear-history-reminder.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { CLEAR_HISTORY_REMINDER_TIME } from '../../constants';
import { getIpcApi } from '../../lib/get-ipc-api';
import { Message } from '../../stores/chat-slice';
import AIClearHistoryReminder from '../ai-clear-history-reminder';
import type { Message } from '../../hooks/use-assistant';

jest.mock( '../../lib/get-ipc-api' );

87 changes: 49 additions & 38 deletions src/components/tests/assistant-code-block.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import store from 'src/stores';
import { useSiteDetails } from '../../hooks/use-site-details';
import { getIpcApi } from '../../lib/get-ipc-api';
import createCodeComponent from '../assistant-code-block';
import createCodeComponent, { CodeBlockProps } from '../assistant-code-block';

jest.mock( '../../lib/get-ipc-api' );
jest.mock( '../../hooks/use-check-installed-apps', () => ( {
@@ -28,85 +30,93 @@ const selectedSite: SiteDetails = {
} );

describe( 'createCodeComponent', () => {
const contextProps = {
blocks: [],
updateMessage: jest.fn(),
siteId: '1',
messageId: 1,
};
const CodeBlock = createCodeComponent( contextProps );
function ContextWrapper( props: CodeBlockProps ) {
const contextProps = {
blocks: [],
updateMessage: jest.fn(),
siteId: '1',
messageId: 1,
};
const CodeBlock = createCodeComponent( contextProps );

return (
<Provider store={ store }>
<CodeBlock { ...props } />
</Provider>
);
}

it( 'should render inline styles for language-generic code', () => {
render( <CodeBlock children="example-code" /> );
render( <ContextWrapper children="example-code" /> );

expect( screen.getByText( 'example-code' ) ).toBeVisible();
expect( screen.queryByText( 'Copy' ) ).not.toBeInTheDocument();
expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );

it( 'should display a "copy" button for language-specific code', () => {
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );

expect( screen.getByText( 'Copy' ) ).toBeVisible();
} );

it( 'should display the "run" button for eligible wp-cli commands without placeholder content', () => {
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );

expect( screen.getByText( 'Run' ) ).toBeVisible();
} );

it( 'should hide the "run" button for ineligible non-wp-cli code', () => {
render( <CodeBlock className="language-bash" children="echo 'Hello, World!'" /> );
render( <ContextWrapper className="language-bash" children="echo 'Hello, World!'" /> );

expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );

it( 'should hide the "run" button for ineligible wp-cli commands with placeholder content', () => {
render(
<CodeBlock className="language-bash" children="wp plugin activate <example-plugin>" />
<ContextWrapper className="language-bash" children="wp plugin activate <example-plugin>" />
);
expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();

render(
<CodeBlock className="language-bash" children="wp plugin activate [example-plugin]" />
<ContextWrapper className="language-bash" children="wp plugin activate [example-plugin]" />
);
expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();

render(
<CodeBlock className="language-bash" children="wp plugin activate {example-plugin}" />
<ContextWrapper className="language-bash" children="wp plugin activate {example-plugin}" />
);
expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();

render(
<CodeBlock className="language-bash" children="wp plugin activate (example-plugin)" />
<ContextWrapper className="language-bash" children="wp plugin activate (example-plugin)" />
);
expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );

it( 'should hide the "run" button for ineligible wp-cli commands with multiple wp-cli invocations', () => {
render( <CodeBlock className="language-bash" children="wp --version wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version wp --version" /> );

expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );
it( 'should hide the "run" button for unsupported commands db', () => {
render( <CodeBlock className="language-bash" children="wp db export" /> );
render( <ContextWrapper className="language-bash" children="wp db export" /> );

expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );
it( 'should hide the "run" button for unsupported commands shell', () => {
render( <CodeBlock className="language-bash" children="wp shell" /> );
render( <ContextWrapper className="language-bash" children="wp shell" /> );

expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );
it( 'should hide the "run" button for unsupported commands server', () => {
render( <CodeBlock className="language-bash" children="wp server" /> );
render( <ContextWrapper className="language-bash" children="wp server" /> );

expect( screen.queryByText( 'Run' ) ).not.toBeInTheDocument();
} );

it( 'should display the "run" button for elligble wp-cli commands that contain a placeholder char', () => {
render( <CodeBlock className="language-bash" children="wp eval 'var_dump(3 < 4);'" /> );
render( <ContextWrapper className="language-bash" children="wp eval 'var_dump(3 < 4);'" /> );

expect( screen.getByText( 'Run' ) ).toBeInTheDocument();
} );
@@ -124,7 +134,7 @@ describe( 'createCodeComponent', () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( { stdout: 'Mock success', stderr: '' } ),
} );
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );
expect( screen.queryByText( 'Running...' ) ).not.toBeInTheDocument();

fireEvent.click( screen.getByText( 'Run' ) );
@@ -140,7 +150,7 @@ describe( 'createCodeComponent', () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( { stdout: 'Mock success', stderr: '' } ),
} );
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );

fireEvent.click( screen.getByText( 'Run' ) );

@@ -154,7 +164,7 @@ describe( 'createCodeComponent', () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( { stdout: '', stderr: 'Mock error' } ),
} );
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );

fireEvent.click( screen.getByText( 'Run' ) );

@@ -172,7 +182,7 @@ describe( 'createCodeComponent', () => {
copyText: mockCopyText,
showNotification: jest.fn(),
} );
render( <CodeBlock className="language-bash" children="wp --version" /> );
render( <ContextWrapper className="language-bash" children="wp --version" /> );

fireEvent.click( screen.getByText( 'Copy' ) );

@@ -196,7 +206,12 @@ describe( 'createCodeComponent', () => {
messageId: 1,
};
const CodeBlock = createCodeComponent( contextProps );
render( <CodeBlock className="language-bash" children="wp --version" /> );

render(
<Provider store={ store }>
<CodeBlock className="language-bash" children="wp --version" />
</Provider>
);

expect( screen.getByText( 'Success' ) ).toBeVisible();
expect( screen.getByText( 'Mock success' ) ).toBeVisible();
@@ -213,8 +228,7 @@ describe( 'createCodeComponent', () => {
showNotification: jest.fn(),
} );

const CodeBlock = createCodeComponent( contextProps );
render( <CodeBlock children="wp-content/plugins/hello.php" /> );
render( <ContextWrapper children="wp-content/plugins/hello.php" /> );

await waitFor( () => {
expect( screen.getByText( 'wp-content/plugins/hello.php' ) ).toBeVisible();
@@ -234,8 +248,7 @@ describe( 'createCodeComponent', () => {
openFileInIDE: jest.fn(),
} );

const CodeBlock = createCodeComponent( contextProps );
render( <CodeBlock children="wp-content/debug.log" /> );
render( <ContextWrapper children="wp-content/debug.log" /> );

await waitFor( () => {
expect( screen.getByText( 'wp-content/debug.log' ) ).toBeVisible();
@@ -252,8 +265,7 @@ describe( 'createCodeComponent', () => {
openLocalPath: jest.fn(),
} );

const CodeBlock = createCodeComponent( contextProps );
render( <CodeBlock children="wp-content/plugins" /> );
render( <ContextWrapper children="wp-content/plugins" /> );

await waitFor( () => {
expect( screen.getByText( 'wp-content/plugins' ) ).toBeVisible();
@@ -270,8 +282,7 @@ describe( 'createCodeComponent', () => {
openLocalPath: jest.fn(),
} );

const CodeBlock = createCodeComponent( contextProps );
render( <CodeBlock children="wp-content/plugins" /> );
render( <ContextWrapper children="wp-content/plugins" /> );

await waitFor( () => {
expect( screen.getByText( 'wp-content/plugins' ) ).toBeVisible();
@@ -285,19 +296,19 @@ describe( 'createCodeComponent', () => {

describe( 'when the "open in terminal" button is clicked', () => {
it( 'should not be visible for non-bash code blocks', () => {
render( <CodeBlock className="language-php" children="<?php echo 'Hello'; ?>" /> );
render( <ContextWrapper className="language-php" children="<?php echo 'Hello'; ?>" /> );

expect( screen.queryByText( 'Open in terminal' ) ).not.toBeInTheDocument();
} );

it( 'should be visible for bash code blocks', () => {
render( <CodeBlock className="language-bash" children="wp plugin list" /> );
render( <ContextWrapper className="language-bash" children="wp plugin list" /> );

expect( screen.getByText( 'Open in terminal' ) ).toBeVisible();
} );

it( 'should be visible for sh code blocks', () => {
render( <CodeBlock className="language-sh" children="wp plugin list" /> );
render( <ContextWrapper className="language-sh" children="wp plugin list" /> );

expect( screen.getByText( 'Open in terminal' ) ).toBeVisible();
} );
@@ -308,7 +319,7 @@ describe( 'createCodeComponent', () => {
openTerminalAtPath: jest.fn(),
showNotification: jest.fn(),
} );
render( <CodeBlock className="language-bash" children="wp plugin list" /> );
render( <ContextWrapper className="language-bash" children="wp plugin list" /> );

fireEvent.click( screen.getByText( 'Open in terminal' ) );

138 changes: 78 additions & 60 deletions src/components/tests/content-tab-assistant.test.tsx
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't a completely exhaustive test suite, but it's pretty good, and the limited changes I had to make should hopefully be reassuring.

Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { CLEAR_HISTORY_REMINDER_TIME } from '../../constants';
import { useAuth } from '../../hooks/use-auth';
import { ChatProvider } from '../../hooks/use-chat-context';
import { useGetWpVersion } from '../../hooks/use-get-wp-version';
import { useOffline } from '../../hooks/use-offline';
import { usePromptUsage } from '../../hooks/use-prompt-usage';
import { ThemeDetailsProvider } from '../../hooks/use-theme-details';
import { useWelcomeMessages } from '../../hooks/use-welcome-messages';
import { getIpcApi } from '../../lib/get-ipc-api';
import { ContentTabAssistant, MIMIC_CONVERSATION_DELAY } from '../content-tab-assistant';
import { Provider } from 'react-redux';
import {
ContentTabAssistant,
MIMIC_CONVERSATION_DELAY,
} from 'src/components/content-tab-assistant';
import { CHAT_MESSAGES_STORE_KEY, CLEAR_HISTORY_REMINDER_TIME } from 'src/constants';
import { useAuth } from 'src/hooks/use-auth';
import { useGetWpVersion } from 'src/hooks/use-get-wp-version';
import { useOffline } from 'src/hooks/use-offline';
import { usePromptUsage } from 'src/hooks/use-prompt-usage';
import { ThemeDetailsProvider } from 'src/hooks/use-theme-details';
import { useWelcomeMessages } from 'src/hooks/use-welcome-messages';
import { getIpcApi } from 'src/lib/get-ipc-api';
import store from 'src/stores';
import { setMessages, resetChatState, generateMessage } from 'src/stores/chat-slice';

jest.mock( '../../hooks/use-auth' );
jest.mock( '../../hooks/use-welcome-messages' );
@@ -44,41 +49,50 @@ const runningSite = {
};

const initialMessages = [
{ id: 0, content: 'Initial message 1', role: 'user' },
{ id: 1, content: 'Initial message 2', role: 'assistant' },
generateMessage( 'Initial message 1', 'user', 0, 'chat-id', 10 ),
generateMessage( 'Initial message 2', 'assistant', 1, 'chat-id', 11 ),
];

function ContextWrapper( props: Parameters< typeof ContentTabAssistant >[ 0 ] ) {
return (
<ThemeDetailsProvider>
<ChatProvider>
<Provider store={ store }>
<ThemeDetailsProvider>
<ContentTabAssistant { ...props } />
</ChatProvider>
</ThemeDetailsProvider>
</ThemeDetailsProvider>
</Provider>
);
}

describe( 'ContentTabAssistant', () => {
const clientReqPost = jest.fn().mockResolvedValue( {
id: 'chatcmpl-9USNsuhHWYsPAUNiOhOG2970Hjwwb',
object: 'chat.completion',
created: 1717045976,
model: 'test',
choices: [
const clientReqPost = jest.fn().mockImplementation( ( params, callback ) => {
callback(
null,
{
index: 0,
message: {
id: 0,
role: 'assistant',
content:
'Hello! How can I assist you today? Are you working on a WordPress project, or do you need help with something specific related to WordPress or WP-CLI?',
},
logprobs: null,
finish_reason: 'stop',
id: 'chatcmpl-9USNsuhHWYsPAUNiOhOG2970Hjwwb',
object: 'chat.completion',
created: 1717045976,
model: 'test',
choices: [
{
index: 0,
message: {
id: 0,
role: 'assistant',
content:
'Hello! How can I assist you today? Are you working on a WordPress project, or do you need help with something specific related to WordPress or WP-CLI?',
},
logprobs: null,
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 980, completion_tokens: 36, total_tokens: 1016 },
system_fingerprint: 'fp_777',
},
],
usage: { prompt_tokens: 980, completion_tokens: 36, total_tokens: 1016 },
system_fingerprint: 'fp_777',
{
'x-quota-max': '100',
'x-quota-remaining': '99',
}
);
} );

const authenticate = jest.fn();
@@ -91,6 +105,10 @@ describe( 'ContentTabAssistant', () => {
jest.clearAllMocks();
window.HTMLElement.prototype.scrollIntoView = jest.fn();
localStorage.clear();

// Reset Redux store state
store.dispatch( resetChatState() );

( useAuth as jest.Mock ).mockReturnValue( {
client: {
req: {
@@ -124,9 +142,8 @@ describe( 'ContentTabAssistant', () => {
expect( guideLines ).toHaveTextContent( 'Powered by experimental AI. Learn more' );
} );

test( 'saves and retrieves conversation from localStorage', async () => {
const storageKey = 'ai_chat_messages';
localStorage.setItem( storageKey, JSON.stringify( { [ runningSite.id ]: initialMessages } ) );
test( 'saves and retrieves conversation from Redux state', async () => {
store.dispatch( setMessages( { instanceId: runningSite.id, messages: initialMessages } ) );
render( <ContextWrapper selectedSite={ runningSite } /> );
await waitFor( () => {
expect( screen.getByText( 'Initial message 1' ) ).toBeVisible();
@@ -144,8 +161,8 @@ describe( 'ContentTabAssistant', () => {
} );

await waitFor( () => {
const storedMessages = JSON.parse( localStorage.getItem( storageKey ) || '[]' );
expect( storedMessages[ runningSite.id ] ).toHaveLength( 3 );
const storedMessages = JSON.parse( localStorage.getItem( CHAT_MESSAGES_STORE_KEY ) || '[]' );
expect( storedMessages[ runningSite.id ] ).toHaveLength( 4 );
expect( storedMessages[ runningSite.id ][ 2 ].content ).toBe( 'New message' );
} );
} );
@@ -276,14 +293,17 @@ describe( 'ContentTabAssistant', () => {
} );

test( 'renders Welcome messages and example prompts when the conversation is starts', () => {
store.dispatch( setMessages( { instanceId: runningSite.id, messages: [] } ) );
render( <ContextWrapper selectedSite={ runningSite } /> );

expect( screen.getByText( 'Welcome to our service!' ) ).toBeVisible();
expect( screen.getByText( 'How to create a WordPress site' ) ).toBeVisible();
expect( screen.getByText( 'How to clear cache' ) ).toBeVisible();
expect( screen.getByText( 'How to install a plugin' ) ).toBeVisible();
} );

test( 'renders Welcome messages and example prompts when offline', () => {
store.dispatch( setMessages( { instanceId: runningSite.id, messages: [] } ) );
( useOffline as jest.Mock ).mockReturnValue( true );

render( <ContextWrapper selectedSite={ runningSite } /> );
@@ -295,6 +315,7 @@ describe( 'ContentTabAssistant', () => {
} );

test( 'should manage the focus state when selecting an example prompt', async () => {
store.dispatch( setMessages( { instanceId: runningSite.id, messages: [] } ) );
jest.useRealTimers();
const user = userEvent.setup();
render( <ContextWrapper selectedSite={ runningSite } /> );
@@ -314,6 +335,8 @@ describe( 'ContentTabAssistant', () => {
} );

test( 'renders the selected prompt of Welcome messages and confirms other prompts are removed', async () => {
store.dispatch( setMessages( { instanceId: runningSite.id, messages: [] } ) );

render( <ContextWrapper selectedSite={ runningSite } /> );

await waitFor( () => {
@@ -344,19 +367,14 @@ describe( 'ContentTabAssistant', () => {
jest.useFakeTimers();
jest.setSystemTime( MOCKED_CURRENT_TIME );

const storageKey = 'ai_chat_messages';
localStorage.setItem(
storageKey,
JSON.stringify( {
[ runningSite.id ]: [
{ id: 0, content: 'Initial message 1', role: 'user' },
{
id: 1,
content: 'Initial message 2',
role: 'assistant',
createdAt: OLD_MESSAGE_TIME,
},
],
const messageOne = generateMessage( 'Initial message 1', 'user', 0, 'hej', 10 );
messageOne.createdAt = MOCKED_CURRENT_TIME;
const messageTwo = generateMessage( 'Initial message 2', 'assistant', 1, 'hej', 11 );
messageTwo.createdAt = OLD_MESSAGE_TIME;
store.dispatch(
setMessages( {
instanceId: runningSite.id,
messages: [ messageOne, messageTwo ],
} )
);

@@ -392,14 +410,14 @@ describe( 'ContentTabAssistant', () => {
} );

test( 'renders notices by importance', async () => {
const storageKey = 'ai_chat_messages';
localStorage.setItem(
storageKey,
JSON.stringify( {
[ runningSite.id ]: [
{ id: 0, content: 'Initial message 1', role: 'user' },
{ id: 1, content: 'Initial message 2', role: 'assistant', createdAt: 0 },
],
const messageOne = generateMessage( 'Initial message 1', 'user', 0, 'chat-id', 10 );
messageOne.createdAt = 0;
const messageTwo = generateMessage( 'Initial message 2', 'assistant', 1, 'chat-id', 11 );
messageTwo.createdAt = 0;
store.dispatch(
setMessages( {
instanceId: runningSite.id,
messages: [ messageOne, messageTwo ],
} )
);

1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ export const WPCOM_PROFILE_URL = 'https://wordpress.com/me';
export const STUDIO_DOCS_URL_GET_HELP_UNSUPPORTED_SITES =
'https://developer.wordpress.com/docs/developer-tools/studio/';
export const CHAT_MESSAGES_STORE_KEY = 'ai_chat_messages';
export const CHAT_ID_STORE_KEY = 'ai_chat_ids';

//Import file constants

91 changes: 0 additions & 91 deletions src/hooks/tests/use-assistant-api.test.ts

This file was deleted.

114 changes: 0 additions & 114 deletions src/hooks/tests/use-assistant.test.tsx

This file was deleted.

552 changes: 0 additions & 552 deletions src/hooks/tests/use-chat-context.test.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import store from 'src/stores';
import { useAuth } from '../use-auth';
import { usePromptUsage, PromptUsageProvider } from '../use-prompt-usage';
import type { ReactNode, FC } from 'react';

jest.mock( '../use-auth', () => ( {
useAuth: jest.fn(),
@@ -10,6 +13,14 @@ jest.mock( '../use-feature-flags', () => ( {
useFeatureFlags: jest.fn(),
} ) );

function TestWrapper( { children }: { children: ReactNode } ) {
return (
<Provider store={ store }>
<PromptUsageProvider>{ children }</PromptUsageProvider>
</Provider>
);
}

describe( 'usePromptUsage hook', () => {
const mockClient = {
req: {
@@ -30,7 +41,7 @@ describe( 'usePromptUsage hook', () => {

it( 'should initialize with default values', () => {
const { result } = renderHook( () => usePromptUsage(), {
wrapper: PromptUsageProvider,
wrapper: TestWrapper,
} );

expect( result.current.promptLimit ).toBe( 200 );
@@ -48,7 +59,7 @@ describe( 'usePromptUsage hook', () => {
} );

const { result } = renderHook( () => usePromptUsage(), {
wrapper: PromptUsageProvider,
wrapper: TestWrapper,
} );

await waitFor( () => {
@@ -61,7 +72,7 @@ describe( 'usePromptUsage hook', () => {

it( 'should update prompt usage', async () => {
const { result } = renderHook( () => usePromptUsage(), {
wrapper: PromptUsageProvider,
wrapper: TestWrapper,
} );

act( () => {
@@ -74,7 +85,7 @@ describe( 'usePromptUsage hook', () => {
expect( result.current.daysUntilReset ).toBe( NaN );
} );

it.only( 'should not allow sending message when limit is reached', async () => {
it( 'should not allow sending message when limit is reached', async () => {
( useAuth as jest.Mock ).mockReturnValue( { client: mockClient } );
mockClient.req.get.mockResolvedValue( {
max_quota: 100,
@@ -83,7 +94,7 @@ describe( 'usePromptUsage hook', () => {
} );

const { result } = renderHook( () => usePromptUsage(), {
wrapper: PromptUsageProvider,
wrapper: TestWrapper,
} );

await waitFor( () => {
87 changes: 0 additions & 87 deletions src/hooks/use-assistant-api.ts

This file was deleted.

181 changes: 0 additions & 181 deletions src/hooks/use-assistant.ts

This file was deleted.

268 changes: 0 additions & 268 deletions src/hooks/use-chat-context.tsx

This file was deleted.

31 changes: 14 additions & 17 deletions src/hooks/use-execute-cli.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import { __, sprintf } from '@wordpress/i18n';
import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { getIpcApi } from '../lib/get-ipc-api';
import { updateMessage } from '../stores/chat-slice';

export function useExecuteWPCLI(
content: string,
siteId: string | undefined,
updateMessage:
| ( (
id: number,
content: string,
output: string,
status: 'success' | 'error',
time: string
) => void )
| undefined,
messageId: number | undefined
) {
const [ cliOutput, setCliOutput ] = useState< string | null >( null );
const [ cliStatus, setCliStatus ] = useState< 'success' | 'error' | null >( null );
const [ cliTime, setCliTime ] = useState< string | null >( null );
const [ isRunning, setIsRunning ] = useState( false );
const dispatch = useDispatch();

const handleExecute = useCallback( async () => {
setIsRunning( true );
@@ -43,16 +37,19 @@ export function useExecuteWPCLI(
setCliTime( completedIn );
setIsRunning( false );

if ( updateMessage && messageId !== undefined ) {
updateMessage(
messageId,
content,
result.stdout || result.stderr,
result.stderr ? 'error' : 'success',
completedIn || ''
if ( messageId !== undefined ) {
dispatch(
updateMessage( {
cliOutput: result.stdout || result.stderr,
cliStatus: result.stderr ? 'error' : 'success',
cliTime: completedIn || '',
codeBlockContent: content,
messageId,
siteId: siteId || '',
} )
);
}
}, [ content, messageId, siteId, updateMessage ] );
}, [ content, dispatch, messageId, siteId ] );

return {
cliOutput,
11 changes: 11 additions & 0 deletions src/hooks/use-prompt-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as Sentry from '@sentry/electron/renderer';
import { useState, useEffect, useCallback, useMemo, createContext, useContext } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from 'src/stores';
import { LIMIT_OF_PROMPTS_PER_USER } from '../constants';
import { useAuth } from './use-auth';

@@ -42,6 +44,7 @@ const calculateDaysRemaining = ( quotaResetDate: string ): number => {
export function PromptUsageProvider( { children }: PromptUsageProps ) {
const { Provider } = promptUsageContext;

const promptUsageDict = useSelector( ( state: RootState ) => state.chat.promptUsageDict );
const [ promptLimit, setPromptLimit ] = useState( LIMIT_OF_PROMPTS_PER_USER );
const [ promptCount, setPromptCount ] = useState( 0 );
const [ quotaResetDate, setQuotaResetDate ] = useState( '' );
@@ -84,6 +87,14 @@ export function PromptUsageProvider( { children }: PromptUsageProps ) {
fetchPromptUsage();
}, [ fetchPromptUsage, client ] );

useEffect( () => {
if ( promptUsageDict ) {
for ( const siteId in promptUsageDict ) {
updatePromptUsage( promptUsageDict[ siteId ] );
}
}
}, [ promptUsageDict, updatePromptUsage ] );

const daysUntilReset = useMemo(
() => calculateDaysRemaining( quotaResetDate ),
[ quotaResetDate ]
38 changes: 0 additions & 38 deletions src/hooks/use-send-feedback.ts

This file was deleted.

384 changes: 384 additions & 0 deletions src/stores/chat-slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,384 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import * as Sentry from '@sentry/electron/renderer';
import WPCOM from 'wpcom';
import { CHAT_ID_STORE_KEY, CHAT_MESSAGES_STORE_KEY } from 'src/constants';
import { getIpcApi } from 'src/lib/get-ipc-api';
import { RootState } from 'src/stores';
import { DEFAULT_PHP_VERSION } from 'vendor/wp-now/src/constants';

export type Message = {
id?: number;
messageApiId?: number;
content: string;
role: 'user' | 'assistant';
chatApiId?: string;
blocks?: {
cliOutput?: string;
cliStatus?: 'success' | 'error';
cliTime?: string;
codeBlockContent?: string;
}[];
createdAt: number;
failedMessage?: boolean;
feedbackReceived?: boolean;
};

const parseWpCliOutput = ( stdout: string ): string[] => {
try {
const data = JSON.parse( stdout );
return data?.map( ( item: { name: string } ) => item.name ) || [];
} catch ( error ) {
Sentry.captureException( error, { extra: { stdout } } );
return [];
}
};

async function fetchPluginList( siteId: string ): Promise< string[] > {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
siteId,
args: 'plugin list --format=json --status=active',
skipPluginsAndThemes: true,
} );

return stderr ? [] : parseWpCliOutput( stdout );
}

async function fetchThemeList( siteId: string ): Promise< string[] > {
const { stdout, stderr } = await getIpcApi().executeWPCLiInline( {
siteId,
args: 'theme list --format=json',
skipPluginsAndThemes: true,
} );

return stderr ? [] : parseWpCliOutput( stdout );
}

type UpdateFromSiteParams = {
site: SiteDetails;
};

export const updateFromSiteThunk = createAsyncThunk(
'chat/updateFromSite',
async ( { site }: UpdateFromSiteParams ) => {
const [ plugins, themes ] = await Promise.all( [
fetchPluginList( site.id ),
fetchThemeList( site.id ),
] );

return {
plugins,
themes,
};
}
);

type FetchAssistantParams = {
client: WPCOM;
instanceId: string;
isRetry?: boolean;
message: Message;
siteId: string;
};

type FetchAssistantResponseData = {
choices: { message: { content: string; id: number } }[];
id: string;
};

export const fetchAssistantThunk = createAsyncThunk(
'chat/fetchAssistant',
async ( { client, instanceId, siteId }: FetchAssistantParams, thunkAPI ) => {
const state = thunkAPI.getState() as RootState;
const context = {
current_url: state.chat.currentURL,
number_of_sites: state.chat.numberOfSites,
wp_version: state.chat.wpVersion,
php_version: state.chat.phpVersion,
plugins: state.chat.pluginListDict[ siteId ] || [],
themes: state.chat.themeListDict[ siteId ] || [],
current_theme: state.chat.themeName,
is_block_theme: state.chat.isBlockTheme,
ide: state.chat.availableEditors,
site_name: state.chat.siteName,
os: state.chat.os,
};
const messages = state.chat.messagesDict[ instanceId ];
const chatApiId = state.chat.chatApiIdDict[ instanceId ];

const { data, headers } = await new Promise< {
data: FetchAssistantResponseData;
headers: Record< string, string >;
} >( ( resolve, reject ) => {
client.req.post< FetchAssistantResponseData >(
{
path: '/studio-app/ai-assistant/chat',
apiNamespace: 'wpcom/v2',
body: {
messages,
chat_id: chatApiId,
context,
},
},
( error, data, headers ) => {
if ( error ) {
return reject( error );
}
return resolve( { data, headers } );
}
);
} );

return {
chatApiId: data?.id,
maxQuota: headers[ 'x-quota-max' ] || '',
message: data?.choices?.[ 0 ]?.message?.content,
messageApiId: data?.choices?.[ 0 ]?.message?.id,
remainingQuota: headers[ 'x-quota-remaining' ] || '',
};
}
);

type SendFeedbackParams = {
client: WPCOM;
instanceId: string;
messageApiId: number;
ratingValue: number;
};

export const sendFeedbackThunk = createAsyncThunk(
'chat/sendFeedback',
async ( { client, messageApiId, ratingValue, instanceId }: SendFeedbackParams, thunkAPI ) => {
const state = thunkAPI.getState() as RootState;
const chatApiId = state.chat.chatApiIdDict[ instanceId ];

try {
await client.req.post( {
path: `/odie/chat/wpcom-studio-chat/${ chatApiId }/${ messageApiId }/feedback`,
apiNamespace: 'wpcom/v2',
body: {
rating_value: ratingValue,
},
} );
} catch ( error ) {
Sentry.captureException( error );
console.error( error );
}
}
);

const storedMessages = localStorage.getItem( CHAT_MESSAGES_STORE_KEY );
const storedChatIds = localStorage.getItem( CHAT_ID_STORE_KEY );
const EMPTY_MESSAGES: readonly Message[] = Object.freeze( [] );

export interface ChatState {
currentURL: string;
pluginListDict: Record< string, string[] >;
themeListDict: Record< string, string[] >;
numberOfSites: number;
phpVersion: string;
siteName: string;
themeName: string;
isBlockTheme: boolean;
os: string;
availableEditors: string[];
wpVersion: string;
messagesDict: { [ key: string ]: Message[] };
chatApiIdDict: { [ key: string ]: string | undefined };
chatInputBySite: { [ key: string ]: string };
isLoadingDict: Record< string, boolean >;
promptUsageDict: Record< string, { maxQuota: string; remainingQuota: string } >;
}

const initialState: ChatState = {
currentURL: '',
pluginListDict: {},
themeListDict: {},
numberOfSites: 0,
themeName: '',
wpVersion: '',
phpVersion: DEFAULT_PHP_VERSION,
isBlockTheme: false,
os: window.appGlobals?.platform || '',
availableEditors: [],
siteName: '',
messagesDict: storedMessages ? JSON.parse( storedMessages ) : {},
chatApiIdDict: storedChatIds ? JSON.parse( storedChatIds ) : {},
chatInputBySite: {},
isLoadingDict: {},
promptUsageDict: {},
};

export function generateMessage(
content: string,
role: 'user' | 'assistant',
newMessageId: number,
chatApiId?: string,
messageApiId?: number
): Message {
return {
content,
role,
id: newMessageId,
chatApiId,
createdAt: Date.now(),
feedbackReceived: false,
messageApiId,
};
}

const chatSlice = createSlice( {
name: 'chat',
initialState,
reducers: {
resetChatState: ( state ) => {
// Reassigning `initialState` to `state` doesn't work. Probably because of Immer.js.
Object.assign( state, initialState );
Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I am thinking - initial state stores initial values of messagesDict and chatApiIdDict. I mean they were read and saved during initial rendering of Studio. I didn't get completely what is chatApiIdDict but I have assumption that we potentially can have bug here with resetting localSotage to data which were during the initial render of Studio.
Just thoughts out loud, maybe I am wrong, since don't have the whole picture of how everything works yet.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, more thoughts - looks like you went another approach and we can remove this action:
https://github.com/Automattic/studio/pull/826/files#diff-d3d6432d7d681344c73a14e2e30339dbdd7fe784d4446baf42efaf59eed40b33R396-R398

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, resetChatState is really only used in unit tests (and that's also what it's intended for). I'll add a comment about that to clarify 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

The last one thing is I wanted gained to comeback this this specific message #826 (comment)

It's cool that we added comment, makes sense, but theoretically, somebody can use it in code in the future and I have assumption that it can be buggy.
I don't completely understood chatApiIdDict and why do we store a few chats in messagesDict , maybe it's the reason of my next concern, but I would like to discuss it:

For the initialState we retrieve data from localStorage during starting Studio:

const storedMessages = localStorage.getItem( CHAT_MESSAGES_STORE_KEY );
const storedChatIds = localStorage.getItem( CHAT_ID_STORE_KEY );

Then we store it inside initialState object. And these fields are never updated.
So, if somebody decided to use resetChatState, then it will errase all chat history to the initial state of Studio.

Maybe I wasn't enough clear, so I will provide the code, which I think will be selfexplanation of m concern:

const getInitialState = () => (

  const messagesDict = storedMessages ? JSON.parse( storedMessages ) : {};
  const chatApiIdDict = storedChatIds ? JSON.parse( storedChatIds ) : {};
  // !!! here messagesDict and chatApiIdDict should be somehow cleaned up to keep everything except the current chat. Not to the initial state which were during initial loading of Studio

  return {
	currentURL: '',
	pluginListDict: {},
    .......
	messagesDict,
	chatApiIdDict,
	.......

Copy link
Contributor

Choose a reason for hiding this comment

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

And in general, as for me, it's very bad practice to store something like this action in code, only for test purposes.

I propose either to find some way to patch store in test to add this method or at least to add __ prefix to this action and keep the comment of why it's here. In this case we will avoid using it in the future by mistake.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Point taken! 👍 Given that resetChatState is indeed meant for tests mainly, I think it's better not to include any logic that modifies localStorage in this file. I did change it so that we re-read the stored messages and chat API IDs every time the state resets, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And in general, as for me, it's very bad practice to store something like this action in code, only for test purposes.

I get your point. Let me think on this and see if I can come up with a better approach

Copy link
Contributor

Choose a reason for hiding this comment

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

I found this:

  // Inject a new reducer inside the test
store.replaceReducer({
    chat: (state = initialState, action) => {
      if (action.type === 'test/resetChatState') {
        return initialState; // Reset state manually
      }
      return chatReducer(state, action); // Otherwise, use original reducer
    },
  });

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 use it for our case

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I moved the reset state action to a test util file per your suggestion in this thread, @nightnei. Great suggestion 👍 Much cleaner this way.

testReducer is also reusable now, which is an added benefit.

},
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you mean state = initialState? If so, it's because arguments don't work by reference, as in PHP with prefix &. So with assigning you just change the variable inside this function.

Anyay, I found more right approach https://stackoverflow.com/questions/59424523/reset-state-to-initial-with-redux-toolkit

Suggested change
resetChatState: ( state ) => {
// Reassigning `initialState` to `state` doesn't work. Probably because of Immer.js.
Object.assign( state, initialState );
},
resetChatState: ( state ) => initialState,

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Returning initialState makes sense 👍 Less of a workaround

updateFromTheme: (
state,
action: PayloadAction< NonNullable< SiteDetails[ 'themeDetails' ] > >
) => {
state.themeName = action.payload.name;
state.isBlockTheme = action.payload.isBlockTheme;
},
setMessages: (
state,
action: PayloadAction< { instanceId: string; messages: Message[] } >
) => {
const { instanceId, messages } = action.payload;
state.messagesDict[ instanceId ] = messages;
},
setChatInput: ( state, action: PayloadAction< { siteId: string; input: string } > ) => {
const { siteId, input } = action.payload;
state.chatInputBySite[ siteId ] = input;
},
updateMessage: (
state,
action: PayloadAction< {
cliOutput?: string;
cliStatus?: 'success' | 'error';
cliTime?: string;
codeBlockContent: string;
messageId: number;
siteId: string;
} >
) => {
const { cliOutput, cliStatus, cliTime, codeBlockContent, messageId, siteId } = action.payload;

if ( ! state.messagesDict[ siteId ] ) {
state.messagesDict[ siteId ] = [];
}

state.messagesDict[ siteId ].forEach( ( message ) => {
if ( message.id === messageId ) {
message.blocks?.forEach( ( block ) => {
if ( block.codeBlockContent === codeBlockContent ) {
block.cliOutput = cliOutput;
block.cliStatus = cliStatus;
block.cliTime = cliTime;
}
} );
}
} );
},
},
extraReducers: ( builder ) => {
builder
.addCase( updateFromSiteThunk.pending, ( state, action ) => {
const { site } = action.meta.arg;

state.currentURL = `http://localhost:${ site.port }`;
state.phpVersion = site.phpVersion ?? DEFAULT_PHP_VERSION;
state.siteName = site.name;
} )
Copy link
Contributor

Choose a reason for hiding this comment

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

I was looking at code and looks like there is no sense to create separate case for pending, we can keep everything in one place fulfilled. We have there access to meta too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Even if it takes a very short time to resolve updateFromSiteThunk, it still technically makes sense to store data that's immediately available before resolving the promise. If we moved these assignments, we'd also have to duplicate them in the rejected handler.

Copy link
Contributor

Choose a reason for hiding this comment

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

If we moved these assignments, we'd also have to duplicate them in the rejected handler.

But we don't have rejected handler for this case.
And, do we use currentURL etc w/o fulfilling of updateFromSiteThunk?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But we don't have rejected handler for this case.

Then we'd need to add one. Duplicating the logic (or abstracting it) is not very appealing.

And, do we use currentURL etc w/o fulfilling of updateFromSiteThunk?

Only if a user manages to send a message before updateFromSiteThunk is resolved, which admittedly is very unlikely.

.addCase( updateFromSiteThunk.fulfilled, ( state, action ) => {
const { plugins, themes } = action.payload;
const siteId = action.meta.arg.site.id;

state.pluginListDict[ siteId ] = plugins;
state.themeListDict[ siteId ] = themes;
} )
.addCase( fetchAssistantThunk.pending, ( state, action ) => {
const { message, instanceId, isRetry } = action.meta.arg;

state.isLoadingDict[ instanceId ] = true;

if ( ! state.messagesDict[ instanceId ] ) {
state.messagesDict[ instanceId ] = [];
}

if ( ! isRetry ) {
state.messagesDict[ instanceId ].push( message );
} else {
state.messagesDict[ instanceId ].forEach( ( msg ) => {
if ( msg.id === message.id ) {
msg.failedMessage = false;
}
} );
}
} )
.addCase( fetchAssistantThunk.rejected, ( state, action ) => {
const { message, instanceId } = action.meta.arg;

state.isLoadingDict[ instanceId ] = false;

state.messagesDict[ instanceId ].forEach( ( msg ) => {
if ( msg.id === message.id ) {
msg.failedMessage = true;
}
} );
} )
.addCase( fetchAssistantThunk.fulfilled, ( state, action ) => {
const { instanceId } = action.meta.arg;

state.isLoadingDict[ instanceId ] = false;

const message = generateMessage(
action.payload.message,
'assistant',
state.messagesDict[ instanceId ].length,
action.payload.chatApiId,
action.payload.messageApiId
);

state.messagesDict[ instanceId ].push( message );

if ( message.chatApiId ) {
state.chatApiIdDict[ instanceId ] = message.chatApiId;
}

state.promptUsageDict[ instanceId ] = {
maxQuota: action.payload.maxQuota,
remainingQuota: action.payload.remainingQuota,
};
} )
.addCase( sendFeedbackThunk.pending, ( state, action ) => {
const { instanceId, messageApiId } = action.meta.arg;

if ( ! state.messagesDict[ instanceId ] ) {
state.messagesDict[ instanceId ] = [];
}

state.messagesDict[ instanceId ].forEach( ( message ) => {
if ( message.messageApiId === messageApiId ) {
message.feedbackReceived = true;
}
} );
} );
},
selectors: {
selectChatInput: ( state, siteId: string ) => state.chatInputBySite[ siteId ] ?? '',
selectMessages: ( state, instanceId: string ) =>
state.messagesDict[ instanceId ] ?? EMPTY_MESSAGES,
selectChatApiId: ( state, instanceId: string ) => state.chatApiIdDict[ instanceId ],
selectIsLoading: ( state, instanceId: string ) => state.isLoadingDict[ instanceId ] ?? false,
},
} );

export const { updateFromTheme, setMessages, setChatInput, updateMessage, resetChatState } =
chatSlice.actions;

export const { selectChatInput, selectMessages, selectChatApiId, selectIsLoading } =
chatSlice.selectors;
Copy link
Contributor

Choose a reason for hiding this comment

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

WDYT about exporting chatSelectors and chatActions, or something similar, but don't spread them to keep them behind some isolated space. It will be easier to read in code chatAction.setMessage instead of setMessage, since it looks not obvious that it's action and specifically from chat reducer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea – I like it 👍 Will make even more sense once we have more stores.


export default chatSlice.reducer;
Copy link
Contributor

Choose a reason for hiding this comment

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

I would avoid default exports, as mentioned it above

41 changes: 41 additions & 0 deletions src/stores/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import { CHAT_ID_STORE_KEY, CHAT_MESSAGES_STORE_KEY } from 'src/constants';
Copy link
Contributor

Choose a reason for hiding this comment

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

WDYT about adding prefix for such constants as LOCAL_STORAGE_ or LOCAL_STORAGE_KEY_. It would simpify reading code.

Suggested change
import { CHAT_ID_STORE_KEY, CHAT_MESSAGES_STORE_KEY } from 'src/constants';
import { CHAT_ID_STORE_KEY, CHAT_MESSAGES_STORE_KEY } from 'src/constants';

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, makes sense 👍

import chatReducer from 'src/stores/chat-slice';

export type RootState = {
Copy link
Contributor

Choose a reason for hiding this comment

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

I am thinking, what about creating useAppSelector, similar to what I mention here. Reaso and arguments for it are the same

https://github.com/Automattic/studio/pull/826/files#diff-a962836e7056c855240fcab71b4e4f6a56e8462e298844f0b928226d230cda83R41

chat: ReturnType< typeof chatReducer >;
};

const listenerMiddleware = createListenerMiddleware< RootState >();

listenerMiddleware.startListening( {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not critical, more just thinking out loud - should we unsubscribe during logout?
I don't predict any issues with it and it's not about performance problems, since Studio is small. But maybe as an initial example, maybe would be nice to implement it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this would be easier to accomplish if the auth data was also stored in Redux. Given that it's not necessary, I would defer this until later.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, makes sense 👍

predicate( action, currentState, previousState ) {
return currentState.chat.messagesDict !== previousState.chat.messagesDict;
},
effect( action, listenerApi ) {
const state = listenerApi.getState() as RootState;
Copy link
Contributor

Choose a reason for hiding this comment

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

It's redundant, sicne you type middleware here

Suggested change
const state = listenerApi.getState() as RootState;
const state = listenerApi.getState();

localStorage.setItem( CHAT_MESSAGES_STORE_KEY, JSON.stringify( state.chat.messagesDict ) );
},
} );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I want to highlight this piece in particular because it illustrates how we could use Redux logic to more effectively update appdata-v1.json, too.

We are subscribing to state changes here, with the predicate function using a plain old === equality check (that works because Redux data is immutable). In the effect callback, we can access the entire state tree and do what we want with it 🙂


listenerMiddleware.startListening( {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at a glance - it's dificult to understand what specific listener do. They look similar and when we have much more listeners - it will be difficult to look up specific one. Maybe let's add some small comment to every listener? WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense 👍

predicate( action, currentState, previousState ) {
return currentState.chat.chatApiIdDict !== previousState.chat.chatApiIdDict;
},
effect( action, listenerApi ) {
const state = listenerApi.getState() as RootState;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const state = listenerApi.getState() as RootState;
const state = listenerApi.getState();

localStorage.setItem( CHAT_ID_STORE_KEY, JSON.stringify( state.chat.chatApiIdDict ) );
},
} );

const store = configureStore( {
reducer: {
chat: chatReducer,
},
middleware: ( getDefaultMiddleware ) =>
getDefaultMiddleware().prepend( listenerMiddleware.middleware ),
} );

export default store;
Copy link
Contributor

Choose a reason for hiding this comment

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

WDYT about avoiding default exports?
I liked such an approach in Tumblr, and IMO, there were reasonable arguments about simplifying looking for import names and consistency with imported names.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, makes sense 👍


export type AppDispatch = typeof store.dispatch;
Copy link
Contributor

Choose a reason for hiding this comment

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

We already started copy-pasting and importing it everywhere. WDYT about creating useAppDispatch and use it, instead of useDispatch? I think code will be less verbose and clear.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea 👍

260 changes: 260 additions & 0 deletions src/stores/tests/chat-slice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import WPCOM from 'wpcom';
import { CHAT_ID_STORE_KEY, CHAT_MESSAGES_STORE_KEY } from 'src/constants';
import { getIpcApi } from 'src/lib/get-ipc-api';
import store from 'src/stores';
import {
fetchAssistantThunk,
generateMessage,
resetChatState,
sendFeedbackThunk,
setMessages,
updateFromSiteThunk,
} from 'src/stores/chat-slice';

jest.mock( 'src/lib/get-ipc-api' );

const mockClientReqPostUsingCallback = jest.fn().mockImplementation( ( params, callback ) => {
callback(
null,
{
id: 'chatcmpl-123',
choices: [
{
message: {
id: 42,
content: 'Test assistant response',
},
},
],
},
{
'x-quota-max': '100',
'x-quota-remaining': '99',
}
);
} );

const mockClientUsingCallback = {
req: { post: mockClientReqPostUsingCallback },
} as unknown as WPCOM;

const mockClientReqPostUsingPromise = jest.fn().mockResolvedValue( {
data: 'success',
} );

const mockClientUsingPromise = {
req: { post: mockClientReqPostUsingPromise },
} as unknown as WPCOM;

describe( 'chat-slice', () => {
beforeEach( () => {
jest.clearAllMocks();
localStorage.clear();
store.dispatch( resetChatState() );
} );

describe( 'fetchAssistantThunk', () => {
it( 'should add assistant message to state when fulfilled', async () => {
const instanceId = 'test-site';
const userMessage = generateMessage( 'Hello test 1', 'user', 0, 'chatcmpl-123', 42 );

const result = await store.dispatch(
fetchAssistantThunk( {
client: mockClientUsingCallback,
instanceId,
message: userMessage,
siteId: instanceId,
} )
);

expect( result.type ).toBe( 'chat/fetchAssistant/fulfilled' );
expect( result.payload ).toEqual( {
chatApiId: 'chatcmpl-123',
maxQuota: '100',
message: 'Test assistant response',
messageApiId: 42,
remainingQuota: '99',
} );

const state = store.getState();
const messages = state.chat.messagesDict[ instanceId ];

expect( messages ).toHaveLength( 2 );
expect( messages[ 0 ] ).toEqual( userMessage );
expect( messages[ 1 ] ).toMatchObject( {
content: 'Test assistant response',
role: 'assistant',
chatApiId: 'chatcmpl-123',
messageApiId: 42,
} );

expect( state.chat.promptUsageDict[ instanceId ] ).toEqual( {
maxQuota: '100',
remainingQuota: '99',
} );
} );

it( 'should mark message as failed when rejected', async () => {
const instanceId = 'test-site';
const userMessage = generateMessage( 'Hello test 2', 'user', 0, 'chatcmpl-123', 42 );

mockClientReqPostUsingCallback.mockImplementationOnce( ( params, callback ) => {
callback( new Error( 'API Error' ), null, {} );
} );

const result = await store.dispatch(
fetchAssistantThunk( {
client: mockClientUsingCallback,
instanceId,
message: userMessage,
siteId: instanceId,
} )
);

expect( result.type ).toBe( 'chat/fetchAssistant/rejected' );

const state = store.getState();
const messages = state.chat.messagesDict[ instanceId ];

expect( messages ).toHaveLength( 1 );
expect( messages[ 0 ] ).toMatchObject( {
...userMessage,
failedMessage: true,
} );
} );
} );

describe( 'sendFeedbackThunk', () => {
it( 'should mark message as feedback received', async () => {
const instanceId = 'test-site';

const userMessage = generateMessage( 'Hello test 3', 'user', 0, 'chatcmpl-123', 42 );
const assistantMessage = generateMessage( 'Response', 'assistant', 1, 'chatcmpl-123', 43 );
store.dispatch( setMessages( { instanceId, messages: [ userMessage, assistantMessage ] } ) );

const result = await store.dispatch(
sendFeedbackThunk( {
client: mockClientUsingPromise,
instanceId,
messageApiId: 42,
ratingValue: 1,
} )
);

expect( result.type ).toBe( 'chat/sendFeedback/fulfilled' );

const state = store.getState();
console.log( 'state', state );
const messages = state.chat.messagesDict[ instanceId ];

expect( messages[ 0 ].feedbackReceived ).toBe( true );
} );
} );

describe( 'localStorage persistence', () => {
it( 'should persist messagesDict and chatApiIdDict changes to localStorage', async () => {
const instanceId = 'test-site';
const userMessage = generateMessage( 'Hello test 4', 'user', 0, 'chatcmpl-123', 42 );

await store.dispatch(
fetchAssistantThunk( {
client: mockClientUsingCallback,
instanceId,
message: userMessage,
siteId: instanceId,
} )
);

const storedMessages = JSON.parse( localStorage.getItem( CHAT_MESSAGES_STORE_KEY ) || '{}' );
expect( storedMessages[ instanceId ] ).toHaveLength( 2 );
expect( storedMessages[ instanceId ][ 0 ] ).toEqual( userMessage );
expect( storedMessages[ instanceId ][ 1 ] ).toMatchObject( {
content: 'Test assistant response',
role: 'assistant',
} );

const storedChatIds = JSON.parse( localStorage.getItem( CHAT_ID_STORE_KEY ) || '{}' );
expect( storedChatIds[ instanceId ] ).toBe( 'chatcmpl-123' );
} );
} );

describe( 'updateFromSiteThunk', () => {
const mockSite: SiteDetails = {
id: 'test-site',
name: 'Test Site',
port: 8881,
phpVersion: '8.0',
path: '/test/path',
running: true,
url: 'http://localhost:8881',
};

beforeEach( () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( {
stdout: JSON.stringify( [ { name: 'woocommerce' }, { name: 'jetpack' } ] ),
stderr: '',
} ),
} );
} );

it( 'should update plugin and theme lists when WP CLI succeeds', async () => {
const result = await store.dispatch( updateFromSiteThunk( { site: mockSite } ) );

expect( result.type ).toBe( 'chat/updateFromSite/fulfilled' );
expect( result.payload ).toEqual( {
plugins: [ 'woocommerce', 'jetpack' ],
themes: [ 'woocommerce', 'jetpack' ],
} );

const state = store.getState();
expect( state.chat.pluginListDict[ mockSite.id ] ).toEqual( [ 'woocommerce', 'jetpack' ] );
expect( state.chat.themeListDict[ mockSite.id ] ).toEqual( [ 'woocommerce', 'jetpack' ] );
expect( state.chat.currentURL ).toBe( 'http://localhost:8881' );
expect( state.chat.phpVersion ).toBe( '8.0' );
expect( state.chat.siteName ).toBe( 'Test Site' );
} );

it( 'should handle WP CLI errors gracefully', async () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( {
stdout: '',
stderr: 'Error: WP CLI failed',
} ),
} );

const result = await store.dispatch( updateFromSiteThunk( { site: mockSite } ) );

expect( result.type ).toBe( 'chat/updateFromSite/fulfilled' );
expect( result.payload ).toEqual( {
plugins: [],
themes: [],
} );

const state = store.getState();
expect( state.chat.pluginListDict[ mockSite.id ] ).toEqual( [] );
expect( state.chat.themeListDict[ mockSite.id ] ).toEqual( [] );
} );

it( 'should handle JSON parsing errors gracefully', async () => {
( getIpcApi as jest.Mock ).mockReturnValue( {
executeWPCLiInline: jest.fn().mockResolvedValue( {
stdout: 'Invalid JSON',
stderr: '',
} ),
} );

const result = await store.dispatch( updateFromSiteThunk( { site: mockSite } ) );

expect( result.type ).toBe( 'chat/updateFromSite/fulfilled' );
expect( result.payload ).toEqual( {
plugins: [],
themes: [],
} );

const state = store.getState();
expect( state.chat.pluginListDict[ mockSite.id ] ).toEqual( [] );
expect( state.chat.themeListDict[ mockSite.id ] ).toEqual( [] );
} );
} );
} );