Skip to content
Open
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
20 changes: 19 additions & 1 deletion src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) : []
}),
)
).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
Expand Down
7 changes: 7 additions & 0 deletions src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ 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
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'

/**
Expand Down Expand Up @@ -56,6 +58,10 @@ export const widgetRegistry = [
name: 'link-preview' as const,
module: linkPreview,
},
{
name: 'quiz' as const,
module: quiz,
},
] as const

/**
Expand Down Expand Up @@ -105,3 +111,4 @@ export type WidgetCacheData =
| linkPreview.CacheData
| weatherForecast.CacheData
| citation.CacheData
| quiz.CacheData
214 changes: 214 additions & 0 deletions src/widgets/quiz/display.tsx
Original file line number Diff line number Diff line change
@@ -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'

Check failure on line 42 in src/widgets/quiz/display.tsx

View workflow job for this annotation

GitHub Actions / typescript

Expected { after 'if' condition
if (option.isCorrect && !isSelected) return 'missed'

Check failure on line 43 in src/widgets/quiz/display.tsx

View workflow job for this annotation

GitHub Actions / typescript

Expected { after 'if' condition
if (!option.isCorrect && isSelected) return 'incorrect'

Check failure on line 44 in src/widgets/quiz/display.tsx

View workflow job for this annotation

GitHub Actions / typescript

Expected { after 'if' condition
return 'idle'
}

const statusStyles: Record<OptionStatus, string> = {
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<OptionStatus, string> = {
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<Set<string>>(() => 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<string>) => {
setSubmitted(true)
onSubmit?.({
selectedIds: [...ids],
correct: gradeQuiz({ prompt, mode, options }, ids),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quiz persist not awaited

Medium Severity

commit calls onSubmit without awaiting the async handleSubmit that persists to the database. If the user sends the next chat message right after checking an answer, aiFetchStreamingResponse may run its getMessage/formatQuizResultsNote pass before the cache write finishes, so the model’s quiz results system note can omit that answer.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 55d46cb. Configure here.

}

const toggleOption = (id: string) => {
if (submitted) return

Check failure on line 92 in src/widgets/quiz/display.tsx

View workflow job for this annotation

GitHub Actions / typescript

Expected { after 'if' condition

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 (
<div className="my-4 w-full">
<div className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="flex flex-col gap-4 p-4 md:p-5">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[length:var(--font-size-xs)] font-medium uppercase tracking-wide text-muted-foreground">
<Sparkles className="size-[var(--icon-size-sm)]" />
<span>{label}</span>
</div>
<p className="text-[length:var(--font-size-body)] font-medium leading-snug text-foreground">{prompt}</p>
</div>

<div className="flex flex-col gap-2">
{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 (
<button
key={option.id}
type="button"
disabled={submitted}
onClick={() => toggleOption(option.id)}
className={cn(
'group flex w-full items-center gap-3 rounded-xl border px-3.5 text-left transition-all',
'min-h-[var(--touch-height-lg)] py-2.5',
'disabled:cursor-default focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
!submitted && 'cursor-pointer active:scale-[0.99]',
statusStyles[status],
)}
>
<span
className={cn(
'flex size-6 shrink-0 items-center justify-center border text-[length:var(--font-size-xs)] font-semibold transition-colors',
isMultiple ? 'rounded-md' : 'rounded-full',
badgeStyles[status],
)}
>
{showCorrect ? (
<Check className="size-3.5" strokeWidth={3} />
) : showIncorrect ? (
<X className="size-3.5" strokeWidth={3} />
) : (
optionLetter(index)
)}
</span>
<span className="flex-1 text-[length:var(--font-size-sm)] leading-snug text-foreground">
{option.text}
</span>
</button>
)
})}
</div>

{isGraded && !submitted && (
<Button
size="default"
disabled={selected.size === 0}
onClick={() => commit(selected)}
className="w-full md:w-auto md:self-end"
>
Check answer
</Button>
)}

{submitted && isGraded && (
<div
className={cn(
'flex items-start gap-2.5 rounded-xl border p-3 text-[length:var(--font-size-sm)]',
result
? 'border-emerald-500/40 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200'
: 'border-red-500/40 bg-red-50 text-red-800 dark:bg-red-950/30 dark:text-red-200',
)}
>
<span className="mt-0.5 shrink-0">
{result ? (
<Check className="size-[var(--icon-size-sm)]" strokeWidth={2.5} />
) : (
<X className="size-[var(--icon-size-sm)]" strokeWidth={2.5} />
)}
</span>
<div className="flex flex-col gap-1">
<span className="font-medium">{result ? 'Correct!' : 'Not quite.'}</span>
{explanation && <span className="text-foreground/80">{explanation}</span>}
</div>
</div>
)}

{submitted && !isGraded && (
<div className="flex items-center gap-2 text-[length:var(--font-size-sm)] text-muted-foreground">
<Lightbulb className="size-[var(--icon-size-sm)] shrink-0" />
<span>Got it — working on that next.</span>
</div>
)}
</div>
</div>
</div>
)
}
19 changes: 19 additions & 0 deletions src/widgets/quiz/index.ts
Original file line number Diff line number Diff line change
@@ -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'
13 changes: 13 additions & 0 deletions src/widgets/quiz/instructions.ts
Original file line number Diff line number Diff line change
@@ -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
<widget:quiz mode="MODE" prompt="QUESTION" options='JSON_ARRAY' explanation="WHY" />
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: <widget:quiz mode="single" prompt="What is the capital of France?" options='[{"id":"a","text":"Paris","isCorrect":true},{"id":"b","text":"Lyon"},{"id":"c","text":"Marseille"}]' explanation="Paris has been France's capital since 508 AD." />`
Loading
Loading