From 55d46cb977fb20e3a44c97e322faea583c0fdf50 Mon Sep 17 00:00:00 2001 From: Jordan Kab Date: Thu, 18 Jun 2026 13:52:15 -0400 Subject: [PATCH] feat: add generalized quiz widget (THU-607) Bring the interactive quiz widget prototyped on the OUP demo branch into the product as a first-class, demo-agnostic widget. - Add src/widgets/quiz: single / multiple / choice modes, client-side grading, answer persistence in the message cache, and restore-on-reload. Includes schema, parser, instructions, presentational Quiz, stories, and lib tests. - Register `quiz` in the widget registry and the cache-data union. - Surface persisted answers to the model via formatQuizResultsNote, injected as a system note in aiFetchStreamingResponse, so it can report scores without asking the user to re-enter choices. Deliberately NOT ported: the hiddenFromUi / ChatTurnActionsProvider hidden-turn machinery from the demo branch. The quiz grades client-side and reports results via the system note instead of dispatching a hidden user turn; on the demo branch that machinery was wired into the provider tree but never actually invoked, so bringing it in would be dead code. It can be introduced separately if a real hidden-turn use case appears. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ai/fetch.ts | 20 ++- src/widgets/index.ts | 7 + src/widgets/quiz/display.tsx | 214 +++++++++++++++++++++++++++++++ src/widgets/quiz/index.ts | 19 +++ src/widgets/quiz/instructions.ts | 13 ++ src/widgets/quiz/lib.test.ts | 102 +++++++++++++++ src/widgets/quiz/lib.ts | 118 +++++++++++++++++ src/widgets/quiz/schema.ts | 46 +++++++ src/widgets/quiz/stories.tsx | 70 ++++++++++ src/widgets/quiz/widget.tsx | 65 ++++++++++ 10 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 src/widgets/quiz/display.tsx create mode 100644 src/widgets/quiz/index.ts create mode 100644 src/widgets/quiz/instructions.ts create mode 100644 src/widgets/quiz/lib.test.ts create mode 100644 src/widgets/quiz/lib.ts create mode 100644 src/widgets/quiz/schema.ts create mode 100644 src/widgets/quiz/stories.tsx create mode 100644 src/widgets/quiz/widget.tsx diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index 3285e01c1..87985c35e 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -13,7 +13,9 @@ import { shouldRetry, } from '@/ai/step-logic' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' +import { getMessage } from '@/dal/chat-messages' import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages' +import { collectQuizEntriesFromCache, formatQuizResultsNote } from '@/widgets/quiz/lib' import { getDb } from '@/db/database' import { getLocalSetting } from '@/stores/local-settings-store' import { isSsoMode } from '@/lib/auth-mode' @@ -614,12 +616,28 @@ export const aiFetchStreamingResponse = async ({ } const skillSystemMessages = resolveSkillTokenInstructions(lastUserText, instructionBySlug) + // Surface the user's persisted quiz answers (stored in each assistant + // message's cache by the quiz widget) so the model can report scores + // without asking the user to re-enter their choices. + const quizEntries = ( + await Promise.all( + messages + .filter((message) => message.role === 'assistant') + .map(async (message) => { + const stored = await getMessage(db, message.id) + return stored?.cache ? collectQuizEntriesFromCache(stored.cache as Record) : [] + }), + ) + ).flat() + const quizResultsNote = formatQuizResultsNote(quizEntries) + const systemNotes = [...skillSystemMessages, ...(quizResultsNote ? [quizResultsNote] : [])] + const stream = createUIMessageStream({ generateId: uuidv7, execute: async ({ writer }) => { const baseMessages = await convertToModelMessages(messages) let currentMessages: typeof baseMessages = [ - ...skillSystemMessages.map((content) => ({ role: 'system' as const, content })), + ...systemNotes.map((content) => ({ role: 'system' as const, content })), ...baseMessages, ] let attemptNumber = 1 diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 3ed952a6c..7a81ed72e 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -22,6 +22,7 @@ import * as citation from './citation' import * as connectIntegration from './connect-integration' import * as documentResult from './document-result' import * as linkPreview from './link-preview' +import * as quiz from './quiz' import * as weatherForecast from './weather-forecast' // Re-export components for easy importing @@ -29,6 +30,7 @@ export { CitationBadge } from './citation' export { ConnectIntegrationWidget } from './connect-integration' export { DocumentResultWidget } from './document-result' export { LinkPreview, LinkPreviewSkeleton, LinkPreviewWidget } from './link-preview' +export { Quiz } from './quiz' export { WeatherForecastWidget } from './weather-forecast' /** @@ -56,6 +58,10 @@ export const widgetRegistry = [ name: 'link-preview' as const, module: linkPreview, }, + { + name: 'quiz' as const, + module: quiz, + }, ] as const /** @@ -105,3 +111,4 @@ export type WidgetCacheData = | linkPreview.CacheData | weatherForecast.CacheData | citation.CacheData + | quiz.CacheData diff --git a/src/widgets/quiz/display.tsx b/src/widgets/quiz/display.tsx new file mode 100644 index 000000000..71b807b12 --- /dev/null +++ b/src/widgets/quiz/display.tsx @@ -0,0 +1,214 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Check, Lightbulb, Sparkles, X } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { gradeQuiz, optionLetter, type QuizData, type QuizOption } from './lib' + +export type QuizSubmission = { + selectedIds: string[] + correct: boolean | null +} + +type QuizProps = QuizData & { + /** Restores a previously-answered quiz (from the message cache). */ + initialSelectedIds?: string[] + initialSubmitted?: boolean + /** Fired once when the user commits an answer, for persistence. */ + onSubmit?: (submission: QuizSubmission) => void +} + +/** Visual state of a single option, derived from selection + submission. */ +type OptionStatus = 'idle' | 'selected' | 'correct' | 'incorrect' | 'missed' + +const getOptionStatus = ({ + option, + isSelected, + submitted, + isGraded, +}: { + option: QuizOption + isSelected: boolean + submitted: boolean + isGraded: boolean +}): OptionStatus => { + if (!submitted || !isGraded) { + return isSelected ? 'selected' : 'idle' + } + if (option.isCorrect && isSelected) return 'correct' + if (option.isCorrect && !isSelected) return 'missed' + if (!option.isCorrect && isSelected) return 'incorrect' + return 'idle' +} + +const statusStyles: Record = { + idle: 'border-border bg-card hover:bg-accent hover:border-border', + selected: 'border-primary bg-accent ring-1 ring-primary', + correct: 'border-emerald-500/60 bg-emerald-50 dark:bg-emerald-950/30', + incorrect: 'border-red-500/60 bg-red-50 dark:bg-red-950/30', + missed: 'border-emerald-500/40 bg-emerald-50/50 dark:bg-emerald-950/15', +} + +const badgeStyles: Record = { + idle: 'border-border text-muted-foreground', + selected: 'border-primary bg-primary text-primary-foreground', + correct: 'border-emerald-500 bg-emerald-500 text-white', + incorrect: 'border-red-500 bg-red-500 text-white', + missed: 'border-emerald-500 text-emerald-600 dark:text-emerald-400', +} + +export const Quiz = ({ + prompt, + mode, + options, + explanation, + initialSelectedIds, + initialSubmitted, + onSubmit, +}: QuizProps) => { + const [selected, setSelected] = useState>(() => new Set(initialSelectedIds)) + const [submitted, setSubmitted] = useState(initialSubmitted ?? false) + + const isGraded = mode !== 'choice' + const isMultiple = mode === 'multiple' + const result = useMemo( + () => (submitted ? gradeQuiz({ prompt, mode, options }, selected) : null), + [submitted, prompt, mode, options, selected], + ) + + const commit = (ids: Set) => { + setSubmitted(true) + onSubmit?.({ + selectedIds: [...ids], + correct: gradeQuiz({ prompt, mode, options }, ids), + }) + } + + const toggleOption = (id: string) => { + if (submitted) return + + if (!isGraded) { + // `choice` mode: selecting an option commits the choice immediately. + const next = new Set([id]) + setSelected(next) + commit(next) + return + } + + setSelected((prev) => { + if (isMultiple) { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + } + return new Set([id]) + }) + } + + const label = isGraded ? (isMultiple ? 'Select all that apply' : 'Choose one') : 'Your call' + + return ( +
+
+
+
+
+ + {label} +
+

{prompt}

+
+ +
+ {options.map((option, index) => { + const isSelected = selected.has(option.id) + const status = getOptionStatus({ option, isSelected, submitted, isGraded }) + const showCorrect = status === 'correct' || status === 'missed' + const showIncorrect = status === 'incorrect' + + return ( + + ) + })} +
+ + {isGraded && !submitted && ( + + )} + + {submitted && isGraded && ( +
+ + {result ? ( + + ) : ( + + )} + +
+ {result ? 'Correct!' : 'Not quite.'} + {explanation && {explanation}} +
+
+ )} + + {submitted && !isGraded && ( +
+ + Got it — working on that next. +
+ )} +
+
+
+ ) +} diff --git a/src/widgets/quiz/index.ts b/src/widgets/quiz/index.ts new file mode 100644 index 000000000..6dab6f895 --- /dev/null +++ b/src/widgets/quiz/index.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { Quiz } from './display' +export { instructions } from './instructions' +export { + collectQuizEntriesFromCache, + formatQuizResultsNote, + gradeQuiz, + optionLetter, + type QuizCacheEntry, + type QuizData, + type QuizMode, + type QuizOption, +} from './lib' +export { parse, schema } from './schema' +export type { CacheData, QuizWidget } from './schema' +export { QuizWidget as Component } from './widget' diff --git a/src/widgets/quiz/instructions.ts b/src/widgets/quiz/instructions.ts new file mode 100644 index 000000000..da50b0844 --- /dev/null +++ b/src/widgets/quiz/instructions.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const instructions = `## Quiz + +An interactive multiple-choice quiz the user can answer inline. Prefer this over a markdown list whenever you ask the user a multiple-choice question. +- mode: "single" (exactly one correct answer), "multiple" (one or more correct answers), or "choice" (no correct answer — an open prompt like "What do you want to do next?") +- prompt: the question or prompt text +- options: a JSON array wrapped in SINGLE quotes. Each option is {"id":"a","text":"..."}; for graded modes add "isCorrect":true to correct options. Never set isCorrect in "choice" mode. +- explanation (optional): a short note shown after the user answers (graded modes only) +Emit one widget per question. Do not also list the answers in text — the widget reveals them. +Example: ` diff --git a/src/widgets/quiz/lib.test.ts b/src/widgets/quiz/lib.test.ts new file mode 100644 index 000000000..64859bc57 --- /dev/null +++ b/src/widgets/quiz/lib.test.ts @@ -0,0 +1,102 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, test } from 'bun:test' +import { + collectQuizEntriesFromCache, + formatQuizResultsNote, + gradeQuiz, + optionLetter, + type QuizCacheEntry, + type QuizData, +} from './lib' + +const singleQuiz: QuizData = { + prompt: 'Capital of France?', + mode: 'single', + options: [ + { id: 'a', text: 'Paris', isCorrect: true }, + { id: 'b', text: 'Lyon' }, + ], +} + +const multiQuiz: QuizData = { + prompt: 'Pick the encryption standards', + mode: 'multiple', + options: [ + { id: 'a', text: 'OpenPGP', isCorrect: true }, + { id: 'b', text: 'S/MIME', isCorrect: true }, + { id: 'c', text: 'Base64' }, + ], +} + +describe('gradeQuiz', () => { + test('single: correct only when the right option is chosen', () => { + expect(gradeQuiz(singleQuiz, new Set(['a']))).toBe(true) + expect(gradeQuiz(singleQuiz, new Set(['b']))).toBe(false) + }) + + test('multiple: all-or-nothing', () => { + expect(gradeQuiz(multiQuiz, new Set(['a', 'b']))).toBe(true) + expect(gradeQuiz(multiQuiz, new Set(['a']))).toBe(false) // missing one + expect(gradeQuiz(multiQuiz, new Set(['a', 'b', 'c']))).toBe(false) // extra wrong + }) + + test('choice: nothing to grade', () => { + expect(gradeQuiz({ ...singleQuiz, mode: 'choice' }, new Set(['a']))).toBeNull() + }) +}) + +describe('optionLetter', () => { + test('maps index to letter', () => { + expect(optionLetter(0)).toBe('A') + expect(optionLetter(2)).toBe('C') + }) +}) + +describe('collectQuizEntriesFromCache', () => { + test('pulls only quiz-namespaced entries', () => { + const entry: QuizCacheEntry = { + prompt: 'Capital of France?', + mode: 'single', + selectedIds: ['b'], + chosen: ['Lyon'], + correct: false, + } + const cache = { + 'quiz/Capital of France?': entry, + 'linkPreview/https://x.com': { title: 'x' }, + 'weatherForecast/Seattle': { days: [] }, + } + expect(collectQuizEntriesFromCache(cache)).toEqual([entry]) + }) + + test('ignores malformed entries', () => { + expect(collectQuizEntriesFromCache({ 'quiz/bad': { prompt: 'x' } })).toEqual([]) + }) +}) + +describe('formatQuizResultsNote', () => { + test('returns null with no entries', () => { + expect(formatQuizResultsNote([])).toBeNull() + }) + + test('summarizes graded answers with a score', () => { + const note = formatQuizResultsNote([ + { prompt: 'Q1', mode: 'single', selectedIds: ['a'], chosen: ['Paris'], correct: true }, + { prompt: 'Q2', mode: 'single', selectedIds: ['b'], chosen: ['Thames'], correct: false }, + ]) + expect(note).toContain('"Q1" — chose "Paris" — correct') + expect(note).toContain('"Q2" — chose "Thames" — incorrect') + expect(note).toContain('Score: 1/2 (50%).') + }) + + test('omits score for ungraded choice answers', () => { + const note = formatQuizResultsNote([ + { prompt: 'What next?', mode: 'choice', selectedIds: ['draft'], chosen: ['Draft a reply'], correct: null }, + ]) + expect(note).toContain('"What next?" — chose "Draft a reply"') + expect(note).not.toContain('Score:') + }) +}) diff --git a/src/widgets/quiz/lib.ts b/src/widgets/quiz/lib.ts new file mode 100644 index 000000000..eb89e5798 --- /dev/null +++ b/src/widgets/quiz/lib.ts @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Quiz widget interaction modes: + * - `single` — exactly one correct answer (graded, radio-style) + * - `multiple` — one or more correct answers (graded, checkbox-style) + * - `choice` — no correct answer, an open prompt like "What do you want to do next?" + */ +export type QuizMode = 'single' | 'multiple' | 'choice' + +export type QuizOption = { + id: string + text: string + /** Only meaningful for `single` / `multiple` modes. Ignored for `choice`. */ + isCorrect?: boolean +} + +export type QuizData = { + /** The question or prompt shown above the options. */ + prompt: string + mode: QuizMode + options: QuizOption[] + /** Optional context shown after the quiz is answered (graded modes only). */ + explanation?: string +} + +/** + * Grades a set of selected option ids against the quiz answer key. + * Returns `null` for `choice` mode (nothing to grade). + */ +export const gradeQuiz = (data: QuizData, selectedIds: Set): boolean | null => { + if (data.mode === 'choice') { + return null + } + + const correctIds = data.options.filter((o) => o.isCorrect).map((o) => o.id) + const allCorrectSelected = correctIds.every((id) => selectedIds.has(id)) + const noIncorrectSelected = [...selectedIds].every((id) => correctIds.includes(id)) + + return allCorrectSelected && noIncorrectSelected +} + +/** Maps an option index to its display letter (0 → "A", 1 → "B", ...). */ +export const optionLetter = (index: number): string => String.fromCharCode(65 + index) + +/** Namespace prefix for quiz entries stored in a message's cache blob. */ +export const QUIZ_CACHE_PREFIX = 'quiz' + +/** Cache key for a single quiz instance within a message (one tag = one prompt). */ +export const quizStorageKey = (prompt: string): string => `${QUIZ_CACHE_PREFIX}/${prompt}` + +/** + * Persisted record of how the user answered one quiz. Stored under + * {@link quizStorageKey} in the message's `cache` column, and read back both + * to restore the widget UI and to report results to the model on later turns. + */ +export type QuizCacheEntry = { + prompt: string + mode: QuizMode + /** The option ids the user chose — used to restore the widget UI. */ + selectedIds: string[] + /** The option texts the user chose — used to report results to the model. */ + chosen: string[] + /** `true`/`false` for graded modes, `null` for `choice` mode. */ + correct: boolean | null +} + +const isQuizCacheEntry = (value: unknown): value is QuizCacheEntry => + typeof value === 'object' && + value !== null && + 'prompt' in value && + 'chosen' in value && + Array.isArray((value as QuizCacheEntry).chosen) + +/** Pulls quiz answer records out of a message's flat cache blob. */ +export const collectQuizEntriesFromCache = (cache: Record): QuizCacheEntry[] => + Object.entries(cache) + .filter(([key]) => key.startsWith(`${QUIZ_CACHE_PREFIX}/`)) + .map(([, value]) => value) + .filter(isQuizCacheEntry) + +/** + * Renders the user's quiz answers as a system note for the model, so it can + * answer "what did I score?" without asking the user to re-enter their choices. + * Returns `null` when there are no answered quizzes. + */ +export const formatQuizResultsNote = (entries: QuizCacheEntry[]): string | null => { + if (entries.length === 0) { + return null + } + + const lines = entries.map((entry) => { + const chosen = entry.chosen.length > 0 ? entry.chosen.map((c) => `"${c}"`).join(', ') : '(no answer)' + if (entry.correct === null) { + return `- "${entry.prompt}" — chose ${chosen}` + } + return `- "${entry.prompt}" — chose ${chosen} — ${entry.correct ? 'correct' : 'incorrect'}` + }) + + const graded = entries.filter((e) => e.correct !== null) + const score = + graded.length > 0 + ? `\nScore: ${graded.filter((e) => e.correct).length}/${graded.length} (${Math.round( + (graded.filter((e) => e.correct).length / graded.length) * 100, + )}%).` + : '' + + return [ + '## Quiz results', + 'The user answered quiz widgets in this conversation. Use these results if they ask about their answers or score — do not ask them to re-enter their choices.', + ...lines, + score, + ] + .join('\n') + .trim() +} diff --git a/src/widgets/quiz/schema.ts b/src/widgets/quiz/schema.ts new file mode 100644 index 000000000..422caff66 --- /dev/null +++ b/src/widgets/quiz/schema.ts @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createParser } from '@/lib/create-parser' +import { z } from 'zod' +import type { QuizCacheEntry } from './lib' + +const optionShape = z.object({ + id: z.string().min(1), + text: z.string().min(1), + isCorrect: z.boolean().optional(), +}) + +/** + * The `options` attribute arrives as a JSON-encoded string (widget attributes + * are always strings). Parse it leniently, then validate the decoded shape. + */ +const optionsAttr = z + .string() + .transform((value, ctx) => { + try { + return JSON.parse(value) + } catch { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'options must be valid JSON' }) + return z.NEVER + } + }) + .pipe(z.array(optionShape).min(2)) + +export const schema = z.object({ + widget: z.literal('quiz'), + args: z.object({ + prompt: z.string().min(1), + mode: z.enum(['single', 'multiple', 'choice']), + options: optionsAttr, + explanation: z.string().optional(), + }), +}) + +export type QuizWidget = z.infer + +/** The user's persisted answer to a single quiz, stored in the message cache. */ +export type CacheData = QuizCacheEntry + +export const parse = createParser(schema) diff --git a/src/widgets/quiz/stories.tsx b/src/widgets/quiz/stories.tsx new file mode 100644 index 000000000..b42b34c8e --- /dev/null +++ b/src/widgets/quiz/stories.tsx @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Quiz } from './display' +import type { QuizData } from './lib' + +const meta = { + title: 'widgets/quiz', + component: Quiz, + parameters: { + layout: 'centered', + viewport: { defaultViewport: 'responsive' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +const singleAnswer: QuizData = { + mode: 'single', + prompt: 'Which protocol does Thunderbird use to send outgoing mail?', + explanation: 'SMTP (Simple Mail Transfer Protocol) handles sending. IMAP and POP3 are for retrieving mail.', + options: [ + { id: 'imap', text: 'IMAP' }, + { id: 'smtp', text: 'SMTP', isCorrect: true }, + { id: 'pop3', text: 'POP3' }, + { id: 'dav', text: 'CalDAV' }, + ], +} + +const multipleAnswers: QuizData = { + mode: 'multiple', + prompt: 'Which of these are end-to-end encryption standards supported for email?', + explanation: 'Both OpenPGP and S/MIME provide end-to-end encryption. TLS only secures transport.', + options: [ + { id: 'pgp', text: 'OpenPGP', isCorrect: true }, + { id: 'smime', text: 'S/MIME', isCorrect: true }, + { id: 'tls', text: 'TLS (transport only)' }, + { id: 'base64', text: 'Base64 encoding' }, + ], +} + +const noCorrectAnswer: QuizData = { + mode: 'choice', + prompt: 'What would you like to do next?', + options: [ + { id: 'draft', text: 'Draft a reply to this thread' }, + { id: 'summarize', text: 'Summarize the conversation so far' }, + { id: 'schedule', text: 'Schedule a follow-up for tomorrow' }, + { id: 'archive', text: 'Archive and move on' }, + ], +} + +/** One correct answer — radio-style, graded on "Check answer". */ +export const SingleCorrectAnswer: Story = { args: singleAnswer } + +/** Multiple correct answers — checkbox-style, all-or-nothing grading. */ +export const MultipleCorrectAnswers: Story = { args: multipleAnswers } + +/** No correct answer — an open prompt where the choice itself is the action. */ +export const NoCorrectAnswer: Story = { args: noCorrectAnswer } diff --git a/src/widgets/quiz/widget.tsx b/src/widgets/quiz/widget.tsx new file mode 100644 index 000000000..6b5cecb1a --- /dev/null +++ b/src/widgets/quiz/widget.tsx @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { useDatabase } from '@/contexts' +import { getMessage, updateMessageCache } from '@/dal/chat-messages' +import { useQuery } from '@tanstack/react-query' + +import { Quiz, type QuizSubmission } from './display' +import { type QuizCacheEntry, type QuizData, quizStorageKey } from './lib' + +type QuizWidgetProps = QuizData & { + messageId: string +} + +/** + * Connects the presentational {@link Quiz} to the message cache: restores a + * prior answer on mount and persists the user's choice on submit. Persisted + * entries are later surfaced to the model (see `formatQuizResultsNote`). + */ +export const QuizWidget = ({ prompt, mode, options, explanation, messageId }: QuizWidgetProps) => { + const db = useDatabase() + const storageKey = quizStorageKey(prompt) + + const { data: saved, isPending } = useQuery({ + queryKey: ['quizState', messageId, storageKey], + queryFn: async () => { + const message = await getMessage(db, messageId) + const cache = message?.cache as Record | null | undefined + return (cache?.[storageKey] as QuizCacheEntry | undefined) ?? null + }, + staleTime: Infinity, + gcTime: Infinity, + }) + + const handleSubmit = async ({ selectedIds, correct }: QuizSubmission) => { + const chosen = selectedIds.map((id) => options.find((o) => o.id === id)?.text ?? id) + const entry: QuizCacheEntry = { prompt, mode, selectedIds, chosen, correct } + // Persist the choice so the quiz restores answered on reload. We intentionally + // do NOT dispatch a follow-up turn: the widget already grades and reveals the + // answer client-side, so a question stands on its own, and the persisted entry + // is surfaced to the model on later turns via formatQuizResultsNote. Sending a + // turn per answer would also goad single-question-at-a-time backends into + // endlessly asking the next question. + await updateMessageCache(db, messageId, storageKey, entry) + } + + // Wait for the cached answer before seeding the (lazy) initial state, so a + // restored quiz doesn't briefly flash as unanswered. + if (isPending) { + return
+ } + + return ( + + ) +}