Skip to content
Closed

Azamjb #11128

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
49 changes: 49 additions & 0 deletions app/components/challenge/ChallengeCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="cursor-pointer bg-bolt-elements-background-depth-2 rounded-lg shadow border border-bolt-elements-borderColor hover:shadow-lg transition flex flex-col overflow-hidden group w-full max-w-xs mx-auto"
onClick={onClick}
tabIndex={0}
role="button"
aria-label={`Open challenge ${title}`}
style={{ minHeight: 220 }}
>
<div className="h-40 w-full bg-gray-100 flex items-center justify-center overflow-hidden">
<img
src={image}
alt={title}
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-200"
/>
</div>
<div className="p-3 flex-1 flex flex-col">
<div className="flex items-center justify-between mb-1">
<h2 className="text-base font-bold text-bolt-elements-textPrimary truncate" title={title}>
{title}
</h2>
<div className="flex items-center gap-2">
{typeof averageAccuracy === 'number' && (
<span className="text-xs font-semibold text-blue-500 flex items-center">
{averageAccuracy}%
<span className="mx-2 h-4 border-l border-bolt-elements-borderColor" />
</span>
)}
<span className={`text-xs font-semibold ${difficultyColor}`}>{difficulty}</span>
</div>
</div>
</div>
</div>
);
}
246 changes: 246 additions & 0 deletions app/components/chat/ChallengeChat.client.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<ChallengeChatImpl
challenge={challenge}
initialMessages={initialMessages}
storeMessageHistory={storeMessageHistory}
/>
)}
<ToastContainer
closeButton={({ closeToast }) => {
return (
<button className="Toastify__close-button" onClick={closeToast}>
<div className="i-ph:x text-lg" />
</button>
);
}}
icon={({ type }) => {
/**
* @todo Handle more types if we need them. This may require extra color palettes.
*/
switch (type) {
case 'success': {
return <div className="i-ph:check-bold text-bolt-elements-icon-success text-2xl" />;
}
case 'error': {
return <div className="i-ph:warning-circle-bold text-bolt-elements-icon-error text-2xl" />;
}
}

return undefined;
}}
position="bottom-right"
pauseOnFocusLoss
transition={toastAnimation}
/>
</>
);
}

interface ChallengeChatProps {
challenge: Challenge;
initialMessages: Message[];
storeMessageHistory: (messages: Message[]) => Promise<void>;
}

export const ChallengeChatImpl = memo(({ challenge, initialMessages, storeMessageHistory }: ChallengeChatProps) => {
useShortcuts();

const textareaRef = useRef<HTMLTextAreaElement>(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 (
<ChallengeChatBase
ref={animationScope}
challenge={challenge}
textareaRef={textareaRef}
input={input}
showChat={showChat}
chatStarted={chatStarted}
isStreaming={isLoading}
enhancingPrompt={enhancingPrompt}
promptEnhanced={promptEnhanced}
sendMessage={sendMessage}
messageRef={messageRef}
scrollRef={scrollRef}
handleInputChange={handleInputChange}
handleStop={abort}
messages={messages.map((message, i) => {
if (message.role === 'user') {
return message;
}

return {
...message,
content: parsedMessages[i] || '',
};
})}
enhancePrompt={() => {
enhancePrompt(input, (input) => {
setInput(input);
scrollTextArea();
});
}}
/>
);
});
Loading
Loading