diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000000..898d863179 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "mcp__ide__getDiagnostics", + "WebSearch", + "Bash(pnpm update:*)", + "Bash(pnpm typecheck:*)", + "WebFetch(domain:ai-sdk.dev)", + "Bash(pnpm list:*)", + "WebFetch(domain:www.npmjs.com)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/Amplitude Web 14.png b/Amplitude Web 14.png new file mode 100644 index 0000000000..9daba496db Binary files /dev/null and b/Amplitude Web 14.png differ diff --git a/app/components/challenge/BackToChallengesButton.tsx b/app/components/challenge/BackToChallengesButton.tsx new file mode 100644 index 0000000000..1cd0a5eb19 --- /dev/null +++ b/app/components/challenge/BackToChallengesButton.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useNavigate } from '@remix-run/react'; + +export function BackToChallengesButton() { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/app/components/challenge/ChallengeCard.tsx b/app/components/challenge/ChallengeCard.tsx new file mode 100644 index 0000000000..6280010989 --- /dev/null +++ b/app/components/challenge/ChallengeCard.tsx @@ -0,0 +1,101 @@ +import React, { useState, useEffect } from 'react'; + +// utility to clear solved state on app load +if (typeof window !== 'undefined') { + window.addEventListener('load', () => { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith('challenge-solved-')) { + localStorage.removeItem(key); + } + }); + }); +} + +export type ChallengeCardProps = { + id: string; + title: string; + image: string; + difficulty: 'Easy' | 'Medium' | 'Hard'; + averageAccuracy?: number; // percentage, optional for backward compatibility + onClick?: () => void; + solved?: boolean; +}; + +export function ChallengeCard({ + id, + title, + image, + difficulty, + averageAccuracy, + onClick, + solved: solvedProp, +}: ChallengeCardProps) { + const [solved, setSolved] = useState(solvedProp || false); + + useEffect(() => { + // listen for challenge:submit event and mark as solved if id matches + function handleSubmit(e: CustomEvent) { + if (e.detail && e.detail.id === id) { + setSolved(true); + localStorage.setItem(`challenge-solved-${id}`, '1'); + } + } + window.addEventListener('challenge:submit', handleSubmit as EventListener); + + // on mount, check localStorage + if (localStorage.getItem(`challenge-solved-${id}`) === '1') { + setSolved(true); + } + + return () => { + window.removeEventListener('challenge:submit', handleSubmit as EventListener); + }; + }, [id]); + + const difficultyColor = + difficulty === 'Easy' ? 'text-green-500' : difficulty === 'Medium' ? 'text-yellow-500' : 'text-red-500'; + + return ( +
+
+ {title} + {solved && ( +
+ + + + + Solved! +
+ )} +
+
+
+

+ {title} +

+
+ {typeof averageAccuracy === 'number' && ( + + {averageAccuracy}% + + + )} + {difficulty} +
+
+
+
+ ); +} diff --git a/app/components/challenge/ChallengeNavbar.tsx b/app/components/challenge/ChallengeNavbar.tsx new file mode 100644 index 0000000000..d38f10f5a8 --- /dev/null +++ b/app/components/challenge/ChallengeNavbar.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { BackToChallengesButton } from './BackToChallengesButton'; +import { ChallengeTimer } from './ChallengeTimer'; + +export function ChallengeNavbar({ + challenge, + timerProps, + onSubmit, +}: { + challenge: { id: string }; + timerProps: { + start: boolean; + duration?: number; + onExpire?: () => void; + }; + onSubmit?: () => void; +}) { + return ( + + ); +} diff --git a/app/components/challenge/ChallengeTimer.tsx b/app/components/challenge/ChallengeTimer.tsx new file mode 100644 index 0000000000..d526d6a990 --- /dev/null +++ b/app/components/challenge/ChallengeTimer.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useNavigate } from '@remix-run/react'; +import { SubmissionConfirmation } from './SubmissionConfirmation'; + +export function ChallengeTimer({ + start, + duration = 20 * 60, + onExpire, + challenge, + onPreSubmission, + onSubmission, +}: { + start: boolean; + duration?: number; + onExpire?: () => void; + challenge: { id: string }; + onPreSubmission?: () => void; + onSubmission?: () => void; +}) { + const [secondsLeft, setSecondsLeft] = useState(duration); + const [showConfirmation, setShowConfirmation] = useState(false); + const intervalRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (!start) { + return; + } + + if (intervalRef.current) { + return; + } + + intervalRef.current = setInterval(() => { + setSecondsLeft((s) => { + if (s <= 1) { + clearInterval(intervalRef.current!); + intervalRef.current = null; + + if (onExpire) { + onExpire(); + } + + return 0; + } + + return s - 1; + }); + }, 1000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [start, onExpire]); + + const handlePreSubmission = () => { + if (onPreSubmission) { + onPreSubmission(); + } + }; + + const handleSubmission = () => { + if (onSubmission) { + onSubmission(); + } + + console.log(`Challenge: ${JSON.stringify(challenge)}`); + + // Always execute the default submission logic + if (challenge?.id) { + localStorage.setItem(`challenge-solved-${challenge.id}`, '1'); + window.dispatchEvent(new CustomEvent('challenge:submit', { detail: { id: challenge.id } })); + } + + // TODO get the users mark here + + // Redirect to landing page + navigate('/result?prompt_score=5&quality_score=100&speed_score=5'); + }; + + const minutes = Math.floor(secondsLeft / 60); + const seconds = secondsLeft % 60; + + return ( +
+ + + + + {minutes}:{seconds.toString().padStart(2, '0')} + setShowConfirmation(false)} + onPreSubmission={handlePreSubmission} + onSubmission={handleSubmission} + challenge={challenge} + /> +
+ ); +} diff --git a/app/components/challenge/SubmissionConfirmation.tsx b/app/components/challenge/SubmissionConfirmation.tsx new file mode 100644 index 0000000000..1057382aa7 --- /dev/null +++ b/app/components/challenge/SubmissionConfirmation.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Dialog, DialogRoot, DialogTitle, DialogDescription, DialogButton, DialogClose } from '../ui/Dialog'; + +export interface SubmissionConfirmationProps { + isOpen: boolean; + onClose: () => void; + onPreSubmission: () => void; + onSubmission: () => void; + challenge: { id: string }; +} + +export function SubmissionConfirmation({ + isOpen, + onClose, + onPreSubmission, + onSubmission, + challenge, +}: SubmissionConfirmationProps) { + const handleConfirmSubmission = () => { + onSubmission(); + onClose(); + }; + + const handleCancel = () => { + onClose(); + }; + + React.useEffect(() => { + if (isOpen) { + onPreSubmission(); + } + }, [isOpen, onPreSubmission]); + + return ( + + + + Submit Challenge + + + + Are you ready to submit your solution for this challenge? This action cannot be undone. + +
+ + Cancel + + + Submit Solution + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 9de52dd94a..183f37ad3d 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -58,9 +58,12 @@ export const Artifact = memo(({ messageId }: ArtifactProps) => { workbenchStore.showWorkbench.set(!showWorkbench); }} > -
-
{artifact?.title}
-
Click to open Workbench
+
+
+
+
{artifact?.title}
+
Click to open Workbench
+
@@ -152,6 +155,8 @@ const ActionList = memo(({ actions }: ActionListProps) => {
{status === 'running' ? (
+ ) : status === 'dev-running' ? ( +
) : status === 'pending' ? (
) : status === 'complete' ? ( @@ -197,6 +202,9 @@ function getIconColor(status: ActionState['status']) { case 'running': { return 'text-bolt-elements-loader-progress'; } + case 'dev-running': { + return 'text-bolt-elements-icon-success'; + } case 'complete': { return 'text-bolt-elements-icon-success'; } diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index c4f90f43a1..7d141c849b 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -1,7 +1,6 @@ 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'; @@ -18,13 +17,10 @@ interface BaseChatProps { 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 EXAMPLE_PROMPTS = [ @@ -46,13 +42,10 @@ export const BaseChat = React.forwardRef( showChat = true, chatStarted = false, isStreaming = false, - enhancingPrompt = false, - promptEnhanced = false, messages, input = '', sendMessage, handleInputChange, - enhancePrompt, handleStop, }, ref, @@ -68,7 +61,6 @@ export const BaseChat = React.forwardRef( )} data-chat-visible={showChat} > - {() => }
{!chatStarted && ( @@ -130,7 +122,7 @@ export const BaseChat = React.forwardRef( minHeight: TEXTAREA_MIN_HEIGHT, maxHeight: TEXTAREA_MAX_HEIGHT, }} - placeholder="How can Bolt help you today?" + placeholder="Vibe code here..." translate="no" /> @@ -149,37 +141,13 @@ export const BaseChat = React.forwardRef( /> )} -
-
- enhancePrompt?.()} - > - {enhancingPrompt ? ( - <> -
-
Enhancing prompt...
- - ) : ( - <> -
- {promptEnhanced &&
Prompt enhanced
} - - )} -
-
- {input.length > 3 ? ( + {input.length > 3 ? ( +
Use Shift + Return for a new line
- ) : null} -
+
+ ) : null}
{/* Ghost Element */}
diff --git a/app/components/chat/ChallengeChat.client.tsx b/app/components/chat/ChallengeChat.client.tsx new file mode 100644 index 0000000000..a846b79509 --- /dev/null +++ b/app/components/chat/ChallengeChat.client.tsx @@ -0,0 +1,261 @@ +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 { useAudio, useMessageParser, 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 { setChallengeContext } from '~/lib/challengeSession'; +import { fileModificationsToHTML } from '~/utils/diff'; +import { cubicEasingFn } from '~/utils/easings'; +import { createScopedLogger, renderLogger } from '~/utils/logger'; +import { sendPromptStatusToast } from '~/utils/promptToast'; +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 { playSuccess, playFailure } = useAudio(); + + 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 { 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; + } + + // Store challenge context when first message is sent + if (messages.length === 0) { + setChallengeContext(challenge.id, challenge); + } + + /** + * @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(''); + + // Mark the prompt (non-blocking) + fetch('/api/mark-prompt', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt: _input, question: challenge.title }), + }) + .then((response) => response.json()) + .then((data: any) => { + const message = data.message || 'Prompt evaluated'; + const rating = data.rating || 3; + sendPromptStatusToast(message, rating, playSuccess, playFailure); + }) + .catch((error) => { + console.error('Error marking prompt:', error); + sendPromptStatusToast('Unable to evaluate prompt', 2, playSuccess, playFailure); + }); + + textareaRef.current?.blur(); + }; + + const [messageRef, scrollRef] = useSnapScroll(); + + return ( + { + if (message.role === 'user') { + return message; + } + + return { + ...message, + content: parsedMessages[i] || '', + }; + })} + /> + ); +}); diff --git a/app/components/chat/ChallengeChat.tsx b/app/components/chat/ChallengeChat.tsx new file mode 100644 index 0000000000..f1c44d29b5 --- /dev/null +++ b/app/components/chat/ChallengeChat.tsx @@ -0,0 +1,181 @@ +import type { Message } from 'ai'; +import React, { type RefCallback } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +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 { ChallengeTimer } from '~/components/challenge/ChallengeTimer'; + +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[]; + input?: string; + handleStop?: () => void; + sendMessage?: (event: React.UIEvent, messageInput?: string) => void; + handleInputChange?: (event: React.ChangeEvent) => void; +} + +const TEXTAREA_MIN_HEIGHT = 76; + +export const ChallengeChat = React.forwardRef( + ( + { + challenge, + textareaRef, + messageRef, + scrollRef, + showChat = true, + chatStarted = false, + isStreaming = false, + messages, + input = '', + sendMessage, + handleInputChange, + handleStop, + }, + ref, + ) => { + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + + return ( +
+ {chatStarted && false && ( + + )} +
+
+ {!chatStarted && ( +
+ {challenge.title} +
+

{challenge.title}

+ + {challenge.difficulty} + +
+
+
+

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

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