From fa35a64a469f47852cb6d57334641a95d6262cf2 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:19:00 +0000 Subject: [PATCH 1/2] Add on-demand Hint and Explain buttons to quiz UI for AI interaction --- .../workspace-canvas/QuizContent.tsx | 1222 ++++++++++------- 1 file changed, 702 insertions(+), 520 deletions(-) diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx index 47a0bb25..306c9270 100644 --- a/src/components/workspace-canvas/QuizContent.tsx +++ b/src/components/workspace-canvas/QuizContent.tsx @@ -1,568 +1,750 @@ "use client"; import { useCallback, useState, useEffect, useMemo, useRef } from "react"; -import type { Item, ItemData, QuizData, QuizQuestion, QuizSessionData } from "@/lib/workspace-state/types"; +import type { + Item, + ItemData, + QuizData, + QuizQuestion, + QuizSessionData, +} from "@/lib/workspace-state/types"; import { cn } from "@/lib/utils"; -import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, RotateCcw, Trophy, Plus } from "lucide-react"; +import { + CheckCircle2, + XCircle, + ChevronLeft, + ChevronRight, + RotateCcw, + Trophy, + Plus, + Lightbulb, + MessageCircleQuestion, +} from "lucide-react"; import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; import { toast } from "sonner"; import { useAui } from "@assistant-ui/react"; import { useUIStore } from "@/lib/stores/ui-store"; +import { focusComposerInput } from "@/lib/utils/composer-utils"; interface QuizContentProps { - item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; - isScrollLocked?: boolean; - className?: string; // Optional (e.g. padding when in modal) + item: Item; + onUpdateData: (updater: (prev: ItemData) => ItemData) => void; + isScrollLocked?: boolean; + className?: string; // Optional (e.g. padding when in modal) } -export function QuizContent({ item, onUpdateData, isScrollLocked = false, className }: QuizContentProps) { - const quizData = item.data as QuizData; - const questions = quizData.questions || []; - const aui = useAui(); - - // UI store for card selection - const selectedCardIds = useUIStore((state) => state.selectedCardIds); - const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); - - // Session state - const [currentIndex, setCurrentIndex] = useState(quizData.session?.currentIndex || 0); - const [selectedAnswer, setSelectedAnswer] = useState(null); - const [isSubmitted, setIsSubmitted] = useState(false); - const [answeredQuestions, setAnsweredQuestions] = useState( - quizData.session?.answeredQuestions || [] - ); +export function QuizContent({ + item, + onUpdateData, + isScrollLocked = false, + className, +}: QuizContentProps) { + const quizData = item.data as QuizData; + const questions = quizData.questions || []; + const aui = useAui(); + + // UI store for card selection + const selectedCardIds = useUIStore((state) => state.selectedCardIds); + const toggleCardSelection = useUIStore((state) => state.toggleCardSelection); + + // Session state + const [currentIndex, setCurrentIndex] = useState( + quizData.session?.currentIndex || 0, + ); + const [selectedAnswer, setSelectedAnswer] = useState(null); + const [isSubmitted, setIsSubmitted] = useState(false); + const [answeredQuestions, setAnsweredQuestions] = useState< + QuizSessionData["answeredQuestions"] + >(quizData.session?.answeredQuestions || []); + + // Initialize showResults based on whether quiz is completed + // Quiz is completed ONLY if: completedAt exists AND all questions are answered + const isInitiallyCompleted = !!( + quizData.session?.completedAt && + quizData.session?.answeredQuestions?.length && + questions.length > 0 && + quizData.session.answeredQuestions.length >= questions.length + ); + const [showResults, setShowResults] = useState(isInitiallyCompleted); + + // Track previous question count and IDs to detect when new questions are added + const prevQuestionCountRef = useRef(questions.length); + const prevQuestionIdsRef = useRef>( + new Set(questions.map((q) => q.id)), + ); + + const currentQuestion = questions[currentIndex]; + const totalQuestions = questions.length; + + // Sync effect: ensure showResults state matches reality of questions vs answered + // and handle new questions being added + useEffect(() => { + const prevCount = prevQuestionCountRef.current; + const currentCount = questions.length; + const prevIds = prevQuestionIdsRef.current; + + // Detect if new questions were actually added (not just a re-render) + const currentIds = new Set(questions.map((q) => q.id)); + const questionsAdded = questions.filter((q) => !prevIds.has(q.id)).length; + + // Check if we have unanswered questions + const hasUnansweredQuestions = answeredQuestions.length < currentCount; + + if (questionsAdded > 0 && currentCount > prevCount) { + // New questions were added + + if (showResults) { + // If we were showing results, we need to hide them and let user answer new questions + toast.success( + `${questionsAdded} new question${questionsAdded > 1 ? "s" : ""} added! Continue your quiz.`, + ); + setShowResults(false); + setCurrentIndex(prevCount); // Go to first new question + setSelectedAnswer(null); + setIsSubmitted(false); - // Initialize showResults based on whether quiz is completed - // Quiz is completed ONLY if: completedAt exists AND all questions are answered - const isInitiallyCompleted = !!( - quizData.session?.completedAt && - quizData.session?.answeredQuestions?.length && - questions.length > 0 && - quizData.session.answeredQuestions.length >= questions.length - ); - const [showResults, setShowResults] = useState(isInitiallyCompleted); - - // Track previous question count and IDs to detect when new questions are added - const prevQuestionCountRef = useRef(questions.length); - const prevQuestionIdsRef = useRef>(new Set(questions.map(q => q.id))); - - const currentQuestion = questions[currentIndex]; - const totalQuestions = questions.length; - - // Sync effect: ensure showResults state matches reality of questions vs answered - // and handle new questions being added - useEffect(() => { - const prevCount = prevQuestionCountRef.current; - const currentCount = questions.length; - const prevIds = prevQuestionIdsRef.current; - - // Detect if new questions were actually added (not just a re-render) - const currentIds = new Set(questions.map(q => q.id)); - const questionsAdded = questions.filter(q => !prevIds.has(q.id)).length; - - // Check if we have unanswered questions - const hasUnansweredQuestions = answeredQuestions.length < currentCount; - - if (questionsAdded > 0 && currentCount > prevCount) { - // New questions were added - - if (showResults) { - // If we were showing results, we need to hide them and let user answer new questions - toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added! Continue your quiz.`); - setShowResults(false); - setCurrentIndex(prevCount); // Go to first new question - setSelectedAnswer(null); - setIsSubmitted(false); - - // Clear completedAt since we're no longer complete - onUpdateData((prev) => { - const current = prev as QuizData; - return { - ...current, - session: { - ...current.session, - currentIndex: prevCount, - completedAt: undefined, - } as QuizSessionData, - }; - }); - } else { - // Just notify user - toast.success(`${questionsAdded} new question${questionsAdded > 1 ? 's' : ''} added!`); - } - } else if (showResults && hasUnansweredQuestions) { - // Sanity check: if showing results but we have unanswered questions (e.g. from sync mismatch), - // force exit results mode - setShowResults(false); - } - - // Update refs for next comparison - prevQuestionCountRef.current = currentCount; - prevQuestionIdsRef.current = currentIds; - }, [questions, showResults, answeredQuestions.length, currentIndex, onUpdateData]); - - // Check if current question was already answered - const previousAnswer = useMemo(() => { - return answeredQuestions.find(a => a.questionId === currentQuestion?.id); - }, [answeredQuestions, currentQuestion?.id]); - - // Restore state when navigating to previously answered question - useEffect(() => { - if (previousAnswer) { - setSelectedAnswer(previousAnswer.userAnswer); - setIsSubmitted(true); - } else { - setSelectedAnswer(null); - setIsSubmitted(false); - } - }, [currentIndex, previousAnswer]); - - // Persist session state - const persistSession = useCallback((updates: Partial) => { + // Clear completedAt since we're no longer complete onUpdateData((prev) => { - const current = prev as QuizData; - return { - ...current, - session: { - currentIndex, - answeredQuestions, - ...current.session, - ...updates, - }, - }; + const current = prev as QuizData; + return { + ...current, + session: { + ...current.session, + currentIndex: prevCount, + completedAt: undefined, + } as QuizSessionData, + }; }); - }, [onUpdateData, currentIndex, answeredQuestions]); - - // Handle answer selection - const handleSelectAnswer = (index: number) => { - if (isSubmitted) return; - setSelectedAnswer(index); - }; - - // Handle answer submission - const handleSubmit = () => { - if (selectedAnswer === null || isSubmitted) return; - - const isCorrect = selectedAnswer === currentQuestion.correctIndex; - const newAnswer = { - questionId: currentQuestion.id, - userAnswer: selectedAnswer, - isCorrect, - }; - - const newAnsweredQuestions = [ - ...answeredQuestions.filter(a => a.questionId !== currentQuestion.id), - newAnswer, - ]; - - setAnsweredQuestions(newAnsweredQuestions); - setIsSubmitted(true); + } else { + // Just notify user + toast.success( + `${questionsAdded} new question${questionsAdded > 1 ? "s" : ""} added!`, + ); + } + } else if (showResults && hasUnansweredQuestions) { + // Sanity check: if showing results but we have unanswered questions (e.g. from sync mismatch), + // force exit results mode + setShowResults(false); + } - // Persist to data - persistSession({ + // Update refs for next comparison + prevQuestionCountRef.current = currentCount; + prevQuestionIdsRef.current = currentIds; + }, [ + questions, + showResults, + answeredQuestions.length, + currentIndex, + onUpdateData, + ]); + + // Check if current question was already answered + const previousAnswer = useMemo(() => { + return answeredQuestions.find((a) => a.questionId === currentQuestion?.id); + }, [answeredQuestions, currentQuestion?.id]); + + // Restore state when navigating to previously answered question + useEffect(() => { + if (previousAnswer) { + setSelectedAnswer(previousAnswer.userAnswer); + setIsSubmitted(true); + } else { + setSelectedAnswer(null); + setIsSubmitted(false); + } + }, [currentIndex, previousAnswer]); + + // Persist session state + const persistSession = useCallback( + (updates: Partial) => { + onUpdateData((prev) => { + const current = prev as QuizData; + return { + ...current, + session: { currentIndex, - answeredQuestions: newAnsweredQuestions, - startedAt: quizData.session?.startedAt || Date.now(), - }); - }; - - // Navigation - const handleNext = () => { - if (currentIndex < totalQuestions - 1) { - const nextIndex = currentIndex + 1; - setCurrentIndex(nextIndex); - persistSession({ currentIndex: nextIndex }); - } else { - // Show results - setShowResults(true); - persistSession({ completedAt: Date.now() }); - } - }; - - // Arrow navigation - only moves between questions, never shows results - const handleArrowNext = () => { - if (currentIndex < totalQuestions - 1) { - const nextIndex = currentIndex + 1; - setCurrentIndex(nextIndex); - persistSession({ currentIndex: nextIndex }); - } - }; - - const handlePrevious = () => { - if (currentIndex > 0) { - const prevIndex = currentIndex - 1; - setCurrentIndex(prevIndex); - persistSession({ currentIndex: prevIndex }); - } + answeredQuestions, + ...current.session, + ...updates, + }, + }; + }); + }, + [onUpdateData, currentIndex, answeredQuestions], + ); + + // Handle answer selection + const handleSelectAnswer = (index: number) => { + if (isSubmitted) return; + setSelectedAnswer(index); + }; + + // Handle answer submission + const handleSubmit = () => { + if (selectedAnswer === null || isSubmitted) return; + + const isCorrect = selectedAnswer === currentQuestion.correctIndex; + const newAnswer = { + questionId: currentQuestion.id, + userAnswer: selectedAnswer, + isCorrect, }; - const handleRestart = () => { - setCurrentIndex(0); - setSelectedAnswer(null); - setIsSubmitted(false); - setAnsweredQuestions([]); - setShowResults(false); - onUpdateData((prev) => { - const current = prev as QuizData; - return { - ...current, - session: undefined, - }; - }); - }; + const newAnsweredQuestions = [ + ...answeredQuestions.filter((a) => a.questionId !== currentQuestion.id), + newAnswer, + ]; + + setAnsweredQuestions(newAnsweredQuestions); + setIsSubmitted(true); + + // Persist to data + persistSession({ + currentIndex, + answeredQuestions: newAnsweredQuestions, + startedAt: quizData.session?.startedAt || Date.now(), + }); + }; + + // Navigation + const handleNext = () => { + if (currentIndex < totalQuestions - 1) { + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + persistSession({ currentIndex: nextIndex }); + } else { + // Show results + setShowResults(true); + persistSession({ completedAt: Date.now() }); + } + }; + + // Arrow navigation - only moves between questions, never shows results + const handleArrowNext = () => { + if (currentIndex < totalQuestions - 1) { + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + persistSession({ currentIndex: nextIndex }); + } + }; - // Handle Update Quiz - programmatically send message to add more questions - const handleUpdateQuiz = () => { - // First, ensure this card is selected for context - if (!selectedCardIds.has(item.id)) { - toggleCardSelection(item.id); - } - - // Then send the message via composer - const composer = aui?.composer?.(); - if (composer) { - try { - composer.setText("Add 5 more questions to this quiz"); - composer.send(); - toast.success("Requesting more questions..."); - } catch (error) { - toast.error("Failed to send request. Please try again."); - } - } else { - toast.error("Chat not available. Please try again."); - } - }; + const handlePrevious = () => { + if (currentIndex > 0) { + const prevIndex = currentIndex - 1; + setCurrentIndex(prevIndex); + persistSession({ currentIndex: prevIndex }); + } + }; + + const handleRestart = () => { + setCurrentIndex(0); + setSelectedAnswer(null); + setIsSubmitted(false); + setAnsweredQuestions([]); + setShowResults(false); + onUpdateData((prev) => { + const current = prev as QuizData; + return { + ...current, + session: undefined, + }; + }); + }; + + // Handle Update Quiz - programmatically send message to add more questions + const handleUpdateQuiz = () => { + // First, ensure this card is selected for context + if (!selectedCardIds.has(item.id)) { + toggleCardSelection(item.id); + } - // Calculate score - const score = useMemo(() => { - return answeredQuestions.filter(a => a.isCorrect).length; - }, [answeredQuestions]); + // Then send the message via composer + const composer = aui?.composer?.(); + if (composer) { + try { + composer.setText("Add 5 more questions to this quiz"); + composer.send(); + toast.success("Requesting more questions..."); + } catch (error) { + toast.error("Failed to send request. Please try again."); + } + } else { + toast.error("Chat not available. Please try again."); + } + }; - // Prevent focus stealing from chat input - const preventFocusSteal = (e: React.MouseEvent) => { - e.preventDefault(); - }; + const handleAskHint = () => { + if (!selectedCardIds.has(item.id)) { + toggleCardSelection(item.id); + } - // Stop propagation so card click doesn't open modal when interacting with quiz - const stopPropagation = (e: React.MouseEvent) => { - e.stopPropagation(); - }; + const composer = aui?.composer?.(); + if (composer) { + composer.setText( + `Give me a hint for this question in "${item.name}": ${currentQuestion.questionText}`, + ); + useUIStore.getState().setIsChatExpanded(true); + focusComposerInput(true); + } + }; - if (!currentQuestion && !showResults) { - // Template-created items have "Update me" name and should show generating skeleton - const isAwaitingGeneration = item.name === "Update me" && questions.length === 0; + const handleAskExplain = () => { + if (!selectedCardIds.has(item.id)) { + toggleCardSelection(item.id); + } - if (isAwaitingGeneration) { - return ( -
- {/* Question Area Skeleton */} -
-
-
-
-
- Generating quiz questions... -
-
-
- - {/* Options Skeleton */} -
- {[0, 1, 2, 3].map((index) => ( -
-
- - {String.fromCharCode(65 + index)} - -
-
-
-
-
- ))} -
- - {/* Progress Bar Skeleton */} -
-
-
-
-
-
-
-
+ const composer = aui?.composer?.(); + if (composer) { + const userAnswer = + selectedAnswer !== null + ? currentQuestion.options[selectedAnswer] + : "N/A"; + const correctAnswer = + currentQuestion.options[currentQuestion.correctIndex]; + composer.setText( + `Explain this question in "${item.name}": ${currentQuestion.questionText}\n\nI answered: ${userAnswer}\nCorrect answer: ${correctAnswer}`, + ); + useUIStore.getState().setIsChatExpanded(true); + focusComposerInput(true); + } + }; + + // Calculate score + const score = useMemo(() => { + return answeredQuestions.filter((a) => a.isCorrect).length; + }, [answeredQuestions]); + + // Prevent focus stealing from chat input + const preventFocusSteal = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + // Stop propagation so card click doesn't open modal when interacting with quiz + const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + if (!currentQuestion && !showResults) { + // Template-created items have "Update me" name and should show generating skeleton + const isAwaitingGeneration = + item.name === "Update me" && questions.length === 0; + + if (isAwaitingGeneration) { + return ( +
+ {/* Question Area Skeleton */} +
+
+
+
+
+ Generating quiz questions... +
+
+
- {/* Footer Skeleton */} -
-
- {/* Left: Restart Button Skeleton */} -
-
- -
-
- - {/* Center: Navigation Skeleton */} -
-
- -
- -
-
-
- -
-
- - {/* Right: Check Button Skeleton */} -
-
-
-
-
-
+ {/* Options Skeleton */} +
+ {[0, 1, 2, 3].map((index) => ( +
+
+ + {String.fromCharCode(65 + index)} + +
+
+
- ); - } + ))} +
- // User-created quiz with no questions - show empty state - return ( -
-

No questions yet

-

Ask the AI to generate quiz questions

+ {/* Progress Bar Skeleton */} +
+
+
+
+
+
- ); - } +
+ + {/* Footer Skeleton */} +
+
+ {/* Left: Restart Button Skeleton */} +
+
+ +
+
- // Results view - if (showResults) { - const percentage = totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 0; - return ( -
- = 80 ? "text-yellow-400" : percentage >= 50 ? "text-blue-400" : "text-white/50" - )} /> -

Quiz Complete!

-

- {score} / {totalQuestions} -

-

{percentage}% correct

-
- - + {/* Center: Navigation Skeleton */} +
+
+
+ +
+
+
+ +
+
+ + {/* Right: Check Button Skeleton */} +
+
+
+
+
- ); +
+
+ ); } + // User-created quiz with no questions - show empty state return ( -
- {/* Question */} -
-
-
- - {currentQuestion.questionText} - -
-
+
+

+ No questions yet +

+

+ Ask the AI to generate quiz questions +

+
+ ); + } - {/* Options */} -
- {currentQuestion.options.map((option, index) => { - const isSelected = selectedAnswer === index; - const isCorrect = index === currentQuestion.correctIndex; - const showCorrectness = isSubmitted; - - return ( - - ); - })} -
+ // Results view + if (showResults) { + const percentage = + totalQuestions > 0 ? Math.round((score / totalQuestions) * 100) : 0; + return ( +
+ = 80 + ? "text-yellow-400" + : percentage >= 50 + ? "text-blue-400" + : "text-white/50", + )} + /> +

+ Quiz Complete! +

+

+ {score} / {totalQuestions} +

+

+ {percentage}% correct +

+
+ + +
+
+ ); + } + + return ( +
+ {/* Question */} +
+
+
+ + {currentQuestion.questionText} + +
+
-
- {/* Left: spacer */} -
-
+ {/* Options */} +
+ {currentQuestion.options.map((option, index) => { + const isSelected = selectedAnswer === index; + const isCorrect = index === currentQuestion.correctIndex; + const showCorrectness = isSubmitted; - {/* Center: Progress bar */} -
-
-
-
-
+ return ( + + ); + })} +
- {/* Correct/Incorrect feedback */} - {isSubmitted && ( -
-
- {selectedAnswer === currentQuestion.correctIndex ? ( - <> - - Correct! - - ) : ( - <> - - Incorrect - - )} -
-
- )} +
+
+ {!isSubmitted && ( + + )} +
+ + {/* Center: Progress bar */} +
+
+
+
+
- {/* Footer */} -
-
- {/* Left: Restart */} -
- -
- - {/* Center: Navigation arrows with progress dots */} -
- - - {currentIndex + 1} / {totalQuestions} - - -
- - {/* Right: Check/Next Button */} -
- {!isSubmitted ? ( - - ) : ( - - )} -
-
+ {/* Correct/Incorrect feedback */} + {isSubmitted && ( +
+
+ {selectedAnswer === currentQuestion.correctIndex ? ( + <> + + + Correct! + + + ) : ( + <> + + + Incorrect + + + )}
+ +
+ )} +
+ + {/* Footer */} +
+
+ {/* Left: Restart */} +
+ +
+ + {/* Center: Navigation arrows with progress dots */} +
+ + + {currentIndex + 1} / {totalQuestions} + + +
+ + {/* Right: Check/Next Button */} +
+ {!isSubmitted ? ( + + ) : ( + + )} +
- ); +
+
+ ); } export default QuizContent; From 2fa8660dfc822227b9132e16def3a0ee891412a4 Mon Sep 17 00:00:00 2001 From: urjitc <135136842+urjitc@users.noreply.github.com> Date: Fri, 10 Apr 2026 03:34:52 +0000 Subject: [PATCH 2/2] fix(quiz): handle composer errors for hint actions Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../workspace-canvas/QuizContent.tsx | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/components/workspace-canvas/QuizContent.tsx b/src/components/workspace-canvas/QuizContent.tsx index 306c9270..f7e4d287 100644 --- a/src/components/workspace-canvas/QuizContent.tsx +++ b/src/components/workspace-canvas/QuizContent.tsx @@ -280,11 +280,17 @@ export function QuizContent({ const composer = aui?.composer?.(); if (composer) { - composer.setText( - `Give me a hint for this question in "${item.name}": ${currentQuestion.questionText}`, - ); - useUIStore.getState().setIsChatExpanded(true); - focusComposerInput(true); + try { + composer.setText( + `Give me a hint for this question in "${item.name}": ${currentQuestion.questionText}`, + ); + useUIStore.getState().setIsChatExpanded(true); + focusComposerInput(true); + } catch (error) { + toast.error("Failed to send request. Please try again."); + } + } else { + toast.error("Chat not available. Please try again."); } }; @@ -295,17 +301,23 @@ export function QuizContent({ const composer = aui?.composer?.(); if (composer) { - const userAnswer = - selectedAnswer !== null - ? currentQuestion.options[selectedAnswer] - : "N/A"; - const correctAnswer = - currentQuestion.options[currentQuestion.correctIndex]; - composer.setText( - `Explain this question in "${item.name}": ${currentQuestion.questionText}\n\nI answered: ${userAnswer}\nCorrect answer: ${correctAnswer}`, - ); - useUIStore.getState().setIsChatExpanded(true); - focusComposerInput(true); + try { + const userAnswer = + selectedAnswer !== null + ? currentQuestion.options[selectedAnswer] + : "N/A"; + const correctAnswer = + currentQuestion.options[currentQuestion.correctIndex]; + composer.setText( + `Explain this question in "${item.name}": ${currentQuestion.questionText}\n\nI answered: ${userAnswer}\nCorrect answer: ${correctAnswer}`, + ); + useUIStore.getState().setIsChatExpanded(true); + focusComposerInput(true); + } catch (error) { + toast.error("Failed to send request. Please try again."); + } + } else { + toast.error("Chat not available. Please try again."); } };