diff --git a/app/components/challenge/ChallengeCard.tsx b/app/components/challenge/ChallengeCard.tsx new file mode 100644 index 0000000000..36df83241b --- /dev/null +++ b/app/components/challenge/ChallengeCard.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +export type ChallengeCardProps = { + id: string; + title: string; + image: string; + difficulty: 'Easy' | 'Medium' | 'Hard'; + averageAccuracy?: number; // percentage, optional for backward compatibility + onClick?: () => void; +}; + +export function ChallengeCard({ id, title, image, difficulty, averageAccuracy, onClick }: ChallengeCardProps) { + const difficultyColor = + difficulty === 'Easy' ? 'text-green-500' : difficulty === 'Medium' ? 'text-yellow-500' : 'text-red-500'; + return ( +
+
+ {title} +
+
+
+

+ {title} +

+
+ {typeof averageAccuracy === 'number' && ( + + {averageAccuracy}% + + + )} + {difficulty} +
+
+
+
+ ); +} diff --git a/app/components/chat/ChallengeChat.client.tsx b/app/components/chat/ChallengeChat.client.tsx new file mode 100644 index 0000000000..54311ec0af --- /dev/null +++ b/app/components/chat/ChallengeChat.client.tsx @@ -0,0 +1,246 @@ +import { useStore } from '@nanostores/react'; +import type { Message } from 'ai'; +import { useChat } from 'ai/react'; +import { useAnimate } from 'framer-motion'; +import { memo, useEffect, useRef, useState } from 'react'; +import { cssTransition, toast, ToastContainer } from 'react-toastify'; +import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks'; +import { useChatHistory } from '~/lib/persistence'; +import { chatStore } from '~/lib/stores/chat'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { type Challenge } from '~/lib/challenges'; +import { fileModificationsToHTML } from '~/utils/diff'; +import { cubicEasingFn } from '~/utils/easings'; +import { createScopedLogger, renderLogger } from '~/utils/logger'; +import { ChallengeChat as ChallengeChatBase } from './ChallengeChat'; + +const toastAnimation = cssTransition({ + enter: 'animated fadeInRight', + exit: 'animated fadeOutRight', +}); + +const logger = createScopedLogger('ChallengeChat'); + +interface ChallengeChatClientProps { + challenge: Challenge; +} + +export function ChallengeChatClient({ challenge }: ChallengeChatClientProps) { + renderLogger.trace('ChallengeChat'); + + const { ready, initialMessages, storeMessageHistory } = useChatHistory(); + + return ( + <> + {ready && ( + + )} + { + return ( + + ); + }} + icon={({ type }) => { + /** + * @todo Handle more types if we need them. This may require extra color palettes. + */ + switch (type) { + case 'success': { + return
; + } + case 'error': { + return
; + } + } + + return undefined; + }} + position="bottom-right" + pauseOnFocusLoss + transition={toastAnimation} + /> + + ); +} + +interface ChallengeChatProps { + challenge: Challenge; + initialMessages: Message[]; + storeMessageHistory: (messages: Message[]) => Promise; +} + +export const ChallengeChatImpl = memo(({ challenge, initialMessages, storeMessageHistory }: ChallengeChatProps) => { + useShortcuts(); + + const textareaRef = useRef(null); + + const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); + + const { showChat } = useStore(chatStore); + + const [animationScope, animate] = useAnimate(); + + const { messages, isLoading, input, handleInputChange, setInput, stop, append } = useChat({ + api: '/api/chat', + onError: (error) => { + logger.error('Request failed\n\n', error); + toast.error('There was an error processing your request'); + }, + onFinish: () => { + logger.debug('Finished streaming'); + }, + initialMessages, + }); + + const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer(); + const { parsedMessages, parseMessages } = useMessageParser(); + + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + + useEffect(() => { + chatStore.setKey('started', initialMessages.length > 0); + }, []); + + useEffect(() => { + parseMessages(messages, isLoading); + + if (messages.length > initialMessages.length) { + storeMessageHistory(messages).catch((error) => toast.error(error.message)); + } + }, [messages, isLoading, parseMessages]); + + const scrollTextArea = () => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.scrollTop = textarea.scrollHeight; + } + }; + + const abort = () => { + stop(); + chatStore.setKey('aborted', true); + workbenchStore.abortAllActions(); + }; + + useEffect(() => { + const textarea = textareaRef.current; + + if (textarea) { + textarea.style.height = 'auto'; + + const scrollHeight = textarea.scrollHeight; + + textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`; + textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, [input, textareaRef]); + + const runAnimation = async () => { + if (chatStarted) { + return; + } + + await Promise.all([ + animate('#challenge-intro', { opacity: 0, flex: 1 }, { duration: 0.2, ease: cubicEasingFn }), + ]); + + chatStore.setKey('started', true); + + setChatStarted(true); + }; + + const sendMessage = async (_event: React.UIEvent, messageInput?: string) => { + const _input = messageInput || input; + + if (_input.length === 0 || isLoading) { + return; + } + + /** + * @note (delm) Usually saving files shouldn't take long but it may take longer if there + * many unsaved files. In that case we need to block user input and show an indicator + * of some kind so the user is aware that something is happening. But I consider the + * happy case to be no unsaved files and I would expect users to save their changes + * before they send another message. + */ + await workbenchStore.saveAllFiles(); + + const fileModifications = workbenchStore.getFileModifcations(); + + chatStore.setKey('aborted', false); + + runAnimation(); + + if (fileModifications !== undefined) { + const diff = fileModificationsToHTML(fileModifications); + + /** + * If we have file modifications we append a new user message manually since we have to prefix + * the user input with the file modifications and we don't want the new user input to appear + * in the prompt. Using `append` is almost the same as `handleSubmit` except that we have to + * manually reset the input and we'd have to manually pass in file attachments. However, those + * aren't relevant here. + */ + append({ role: 'user', content: `${diff}\n\n${_input}` }); + + /** + * After sending a new message we reset all modifications since the model + * should now be aware of all the changes. + */ + workbenchStore.resetAllFileModifications(); + } else { + append({ role: 'user', content: _input }); + } + + setInput(''); + + resetEnhancer(); + + textareaRef.current?.blur(); + }; + + const [messageRef, scrollRef] = useSnapScroll(); + + return ( + { + if (message.role === 'user') { + return message; + } + + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + enhancePrompt={() => { + enhancePrompt(input, (input) => { + setInput(input); + scrollTextArea(); + }); + }} + /> + ); +}); \ No newline at end of file diff --git a/app/components/chat/ChallengeChat.tsx b/app/components/chat/ChallengeChat.tsx new file mode 100644 index 0000000000..1972d17799 --- /dev/null +++ b/app/components/chat/ChallengeChat.tsx @@ -0,0 +1,193 @@ +import type { Message } from 'ai'; +import React, { type RefCallback } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { Menu } from '~/components/sidebar/Menu.client'; +import { IconButton } from '~/components/ui/IconButton'; +import { Workbench } from '~/components/workbench/Workbench.client'; +import { classNames } from '~/utils/classNames'; +import { type Challenge } from '~/lib/challenges'; +import { Messages } from './Messages.client'; +import { SendButton } from './SendButton.client'; + +import styles from './BaseChat.module.scss'; + +interface ChallengeChatProps { + challenge: Challenge; + textareaRef?: React.RefObject | undefined; + messageRef?: RefCallback | undefined; + scrollRef?: RefCallback | undefined; + showChat?: boolean; + chatStarted?: boolean; + isStreaming?: boolean; + messages?: Message[]; + enhancingPrompt?: boolean; + promptEnhanced?: boolean; + input?: string; + handleStop?: () => void; + sendMessage?: (event: React.UIEvent, messageInput?: string) => void; + handleInputChange?: (event: React.ChangeEvent) => void; + enhancePrompt?: () => void; +} + +const TEXTAREA_MIN_HEIGHT = 76; + +export const ChallengeChat = React.forwardRef( + ( + { + challenge, + textareaRef, + messageRef, + scrollRef, + showChat = true, + chatStarted = false, + isStreaming = false, + enhancingPrompt = false, + promptEnhanced = false, + messages, + input = '', + sendMessage, + handleInputChange, + enhancePrompt, + handleStop, + }, + ref, + ) => { + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + + return ( +
+ {() => } +
+
+ {!chatStarted && ( +
+

+ {challenge.title} +

+
+

+ {challenge.question} +

+

+ Your challenge timer will start right after the first prompt +

+
+
+ )} +
+ + {() => { + return chatStarted ? ( + + ) : null; + }} + +
+
+