Files
soroban-abacus-flashcards/apps/web/src/components/practice/ActiveSession.tsx
2025-12-28 15:11:48 -06:00

1933 lines
63 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { animated, useSpring } from '@react-spring/web'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import type {
ProblemSlot,
SessionHealth,
SessionPart,
SessionPlan,
SlotResult,
} from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { type AutoPauseStats, calculateAutoPauseInfo, type PauseInfo } from './autoPauseCalculator'
import { BrowseModeView, getLinearIndex } from './BrowseModeView'
import { SessionPausedModal } from './SessionPausedModal'
// Re-export types for consumers
export type { AutoPauseStats, PauseInfo }
/**
* Student info needed for the pause modal
*/
export interface StudentInfo {
name: string
emoji: string
color: string
}
import { AbacusDock } from '../AbacusDock'
import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { Tooltip, TooltipProvider } from '../ui/Tooltip'
import { generateCoachHint } from './coachHintGenerator'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
import { useInteractionPhase } from './hooks/useInteractionPhase'
import { usePracticeSoundEffects } from './hooks/usePracticeSoundEffects'
import { NumericKeypad } from './NumericKeypad'
import { PracticeFeedback } from './PracticeFeedback'
import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
import type { ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
/**
* Timing data for the current problem attempt
*/
export interface AttemptTimingData {
/** When the current attempt started */
startTime: number
/** Accumulated pause time in ms */
accumulatedPauseMs: number
}
/**
* Complexity data for broadcast (simplified for transmission)
*/
export interface BroadcastComplexity {
/** Complexity bounds from slot constraints */
bounds?: { min?: number; max?: number }
/** Total complexity cost from generation trace */
totalCost?: number
/** Number of steps (for per-term average) */
stepCount?: number
/** Pre-formatted target skill name */
targetSkillName?: string
}
/**
* Broadcast state for session observation
* This is sent to teachers observing the session in real-time
*/
export interface BroadcastState {
/** Current problem being worked on */
currentProblem: {
terms: number[]
answer: number
}
/** Current phase of the interaction */
phase: 'problem' | 'feedback' | 'tutorial'
/** Student's current answer (empty string if not yet started typing) */
studentAnswer: string
/** Whether the answer is correct (null if not yet submitted) */
isCorrect: boolean | null
/** When the current attempt started (timestamp) */
startedAt: number
/** Purpose of this problem slot (why this problem was selected) */
purpose: 'focus' | 'reinforce' | 'review' | 'challenge'
/** Complexity data for tooltip display */
complexity?: BroadcastComplexity
/** Current problem number (1-indexed for display) */
currentProblemNumber: number
/** Total problems in the session */
totalProblems: number
}
interface ActiveSessionProps {
plan: SessionPlan
/** Student info for display in pause modal */
student: StudentInfo
/** Called when a problem is answered */
onAnswer: (result: Omit<SlotResult, 'timestamp' | 'partNumber'>) => Promise<void>
/** Called when session is ended early */
onEndEarly: (reason?: string) => void
/** Called when session is paused (with info about why) - for external HUD updates */
onPause?: (pauseInfo: PauseInfo) => void
/** Called when session is resumed - for external HUD updates */
onResume?: () => void
/** Called when session completes */
onComplete: () => void
/** Called with timing data when it changes (for external timing display) */
onTimingUpdate?: (timing: AttemptTimingData | null) => void
/** Called with broadcast state when it changes (for session observation) */
onBroadcastStateChange?: (state: BroadcastState | null) => void
/** Whether browse mode is active (controlled externally via toggle in PracticeSubNav) */
isBrowseMode?: boolean
/** Controlled browse index (linear problem index) */
browseIndex?: number
/** Called when browse index changes (for external navigation from progress indicator) */
onBrowseIndexChange?: (index: number) => void
/** Teacher abacus control received from observing teacher */
teacherControl?: ReceivedAbacusControl | null
/** Called after teacher control has been handled (to clear the state) */
onTeacherControlHandled?: () => void
/** Teacher-initiated pause request (from observing teacher) */
teacherPauseRequest?: { message?: string } | null
/** Called after teacher pause has been handled (to clear the state) */
onTeacherPauseHandled?: () => void
/** Teacher-initiated resume request (from observing teacher) */
teacherResumeRequest?: boolean
/** Called after teacher resume has been handled (to clear the state) */
onTeacherResumeHandled?: () => void
/** Manual pause request from parent (HUD pause button) */
manualPauseRequest?: boolean
/** Called after manual pause has been handled (to clear the state) */
onManualPauseHandled?: () => void
}
/**
* Extract the primary skill from constraints for display
*/
function extractTargetSkillName(slot: ProblemSlot): string | null {
const targetSkills = slot.constraints?.targetSkills
if (!targetSkills) return null
// Look for specific skill in targetSkills
for (const [category, skills] of Object.entries(targetSkills)) {
if (skills && typeof skills === 'object') {
const skillKeys = Object.keys(skills)
if (skillKeys.length === 1) {
// Single skill - this is a targeted reinforce/review
return formatSkillName(category, skillKeys[0])
}
}
}
return null
}
/**
* Format a skill ID into a human-readable name
*/
function formatSkillName(category: string, skillKey: string): string {
// Categories: basic, fiveComplements, tenComplements
if (category === 'basic') {
// Format "+3" or "-5" into "add 3" or "subtract 5"
if (skillKey.startsWith('+')) {
return `add ${skillKey.slice(1)}`
}
if (skillKey.startsWith('-')) {
return `subtract ${skillKey.slice(1)}`
}
return skillKey
}
if (category === 'fiveComplements') {
// Format "4=5-1" into "5-complement for 4"
const match = skillKey.match(/^(\d+)=/)
if (match) {
return `5-complement for ${match[1]}`
}
return skillKey
}
if (category === 'tenComplements') {
// Format "9=10-1" into "10-complement for 9"
const match = skillKey.match(/^(\d+)=/)
if (match) {
return `10-complement for ${match[1]}`
}
return skillKey
}
return `${category}: ${skillKey}`
}
/**
* Calculate the number of abacus columns needed to compute a problem.
*
* This considers all intermediate running totals as we progress through the terms,
* not just the final answer. For subtraction (e.g., 87 - 45 = 42), we need
* enough columns for the minuend (87), not just the answer (42).
*/
function calculateAbacusColumns(terms: number[]): number {
if (terms.length === 0) return 1
let runningTotal = 0
let maxAbsValue = 0
for (const term of terms) {
runningTotal += term
maxAbsValue = Math.max(maxAbsValue, Math.abs(runningTotal))
}
return Math.max(1, String(maxAbsValue).length)
}
/**
* Complexity section for purpose tooltip - shows complexity bounds and actual costs
*/
function ComplexitySection({
slot,
showBounds = true,
}: {
slot: ProblemSlot
showBounds?: boolean
}) {
const trace = slot.problem?.generationTrace
const bounds = slot.complexityBounds
const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined)
const hasCost =
trace?.totalComplexityCost !== undefined && !Number.isNaN(trace?.totalComplexityCost)
// Don't render anything if no complexity data
if (!hasBounds && !hasCost) {
return null
}
const sectionStyles = {
container: css({
marginTop: '0.5rem',
padding: '0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
fontSize: '0.8125rem',
}),
header: css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
color: 'gray.400',
fontWeight: '500',
marginBottom: '0.375rem',
}),
row: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'gray.300',
paddingY: '0.125rem',
}),
value: css({
fontFamily: 'mono',
color: 'white',
}),
boundsLabel: css({
color: 'gray.500',
fontSize: '0.75rem',
}),
}
// Format bounds string
let boundsText = ''
if (bounds?.min !== undefined && bounds?.max !== undefined) {
boundsText = `${bounds.min} ${bounds.max}`
} else if (bounds?.min !== undefined) {
boundsText = `${bounds.min}`
} else if (bounds?.max !== undefined) {
boundsText = `${bounds.max}`
}
return (
<div className={sectionStyles.container} data-element="complexity-section">
<div className={sectionStyles.header}>
<span>📊</span>
<span>Complexity</span>
</div>
{showBounds && hasBounds && (
<div className={sectionStyles.row}>
<span className={sectionStyles.boundsLabel}>Required range:</span>
<span className={sectionStyles.value}>{boundsText}</span>
</div>
)}
{hasCost && (
<div className={sectionStyles.row}>
<span>Total cost:</span>
<span className={sectionStyles.value}>{trace.totalComplexityCost}</span>
</div>
)}
{hasCost && trace?.steps && trace.steps.length > 0 && (
<div className={sectionStyles.row}>
<span>Per term (avg):</span>
<span className={sectionStyles.value}>
{(trace.totalComplexityCost! / trace.steps.length).toFixed(1)}
</span>
</div>
)}
</div>
)
}
/**
* Purpose tooltip content - rich explanatory content for each purpose
*/
function PurposeTooltipContent({ slot }: { slot: ProblemSlot }) {
const skillName = extractTargetSkillName(slot)
const tooltipStyles = {
container: css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}),
header: css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
fontWeight: 'bold',
fontSize: '0.9375rem',
}),
emoji: css({
fontSize: '1.125rem',
}),
description: css({
color: 'gray.300',
lineHeight: '1.5',
}),
detail: css({
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.375rem 0.5rem',
backgroundColor: 'gray.800',
borderRadius: '6px',
fontSize: '0.8125rem',
}),
detailLabel: css({
color: 'gray.400',
fontWeight: '500',
}),
detailValue: css({
color: 'white',
fontFamily: 'mono',
}),
percentage: css({
display: 'inline-flex',
alignItems: 'center',
padding: '0.125rem 0.375rem',
backgroundColor: 'orange.900',
color: 'orange.200',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 'bold',
}),
}
switch (slot.purpose) {
case 'focus':
return (
<div className={tooltipStyles.container}>
<div className={tooltipStyles.header}>
<span className={tooltipStyles.emoji}>🎯</span>
<span>Focus Practice</span>
</div>
<p className={tooltipStyles.description}>
Building mastery of your current curriculum skills. These problems are at the heart of
what you&apos;re learning right now.
</p>
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Distribution:</span>
<span className={tooltipStyles.detailValue}>60% of session</span>
</div>
<ComplexitySection slot={slot} />
</div>
)
case 'reinforce':
return (
<div className={tooltipStyles.container}>
<div className={tooltipStyles.header}>
<span className={tooltipStyles.emoji}>💪</span>
<span>Reinforcement</span>
</div>
<p className={tooltipStyles.description}>
Extra practice for skills identified as needing more work. These problems target areas
where mastery is still developing.
</p>
{skillName && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Targeting:</span>
<span className={tooltipStyles.detailValue}>{skillName}</span>
</div>
)}
<ComplexitySection slot={slot} />
</div>
)
case 'review':
return (
<div className={tooltipStyles.container}>
<div className={tooltipStyles.header}>
<span className={tooltipStyles.emoji}>🔄</span>
<span>Spaced Review</span>
</div>
<p className={tooltipStyles.description}>
Keeping mastered skills fresh through spaced repetition. Regular review prevents
forgetting and strengthens long-term memory.
</p>
{skillName && (
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Reviewing:</span>
<span className={tooltipStyles.detailValue}>{skillName}</span>
</div>
)}
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Schedule:</span>
<span className={tooltipStyles.detailValue}>
Mastered: 14 days Practicing: 7 days
</span>
</div>
<ComplexitySection slot={slot} />
</div>
)
case 'challenge':
return (
<div className={tooltipStyles.container}>
<div className={tooltipStyles.header}>
<span className={tooltipStyles.emoji}></span>
<span>Challenge</span>
</div>
<p className={tooltipStyles.description}>
Harder problems that require complement techniques for every term. These push your
skills and build deeper fluency.
</p>
<div className={tooltipStyles.detail}>
<span className={tooltipStyles.detailLabel}>Requirement:</span>
<span className={tooltipStyles.detailValue}>Every term uses complements</span>
</div>
<ComplexitySection slot={slot} />
</div>
)
default:
return null
}
}
/**
* Linear problem display component for Part 3
*/
function LinearProblem({
terms,
userAnswer,
isFocused,
isCompleted,
correctAnswer,
isDark,
detectedPrefixIndex,
}: {
terms: number[]
userAnswer: string
isFocused: boolean
isCompleted: boolean
correctAnswer: number
isDark: boolean
/** Detected prefix index - shows "..." instead of "=" for partial sums */
detectedPrefixIndex?: number
}) {
// Build the equation string
const equation = terms
.map((term, i) => {
if (i === 0) return String(term)
return term < 0 ? ` - ${Math.abs(term)}` : ` + ${term}`
})
.join('')
// Use "..." for prefix sums (mathematically incomplete), "=" for final answer
const isPrefixSum = detectedPrefixIndex !== undefined
const operator = isPrefixSum ? '…' : '='
// Use numeric comparison so "09" equals 9
const numericUserAnswer = parseInt(userAnswer, 10)
const answeredCorrectly = isCompleted && numericUserAnswer === correctAnswer
return (
<div
data-component="linear-problem"
data-prefix-mode={isPrefixSum ? 'true' : undefined}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1rem',
fontFamily: 'monospace',
fontSize: '2rem',
fontWeight: 'bold',
})}
>
<span className={css({ color: isDark ? 'gray.200' : 'gray.800' })}>
{equation}{' '}
<span
className={css({
color: isPrefixSum
? isDark
? 'yellow.400'
: 'yellow.600'
: isDark
? 'gray.200'
: 'gray.800',
})}
>
{operator}
</span>
</span>
<span
className={css({
minWidth: '80px',
padding: '0.5rem 1rem',
borderRadius: '8px',
textAlign: 'center',
backgroundColor: isCompleted
? answeredCorrectly
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'red.900'
: 'red.100'
: isDark
? 'gray.800'
: 'gray.100',
color: isCompleted
? answeredCorrectly
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'red.200'
: 'red.700'
: isDark
? 'gray.200'
: 'gray.800',
border: '2px solid',
borderColor: isFocused ? 'blue.400' : isDark ? 'gray.600' : 'gray.300',
})}
>
{userAnswer || (isFocused ? '?' : '')}
</span>
</div>
)
}
/**
* ActiveSession - The main practice session component
*
* Features:
* - Three-part session structure (abacus, visualization, linear)
* - Part-specific display and instructions
* - Adaptive input (keyboard on desktop, on-screen keypad on mobile)
* - Session health indicators
* - On-screen abacus toggle (for abacus part only)
* - Teacher controls (pause, end early)
*
* State Architecture:
* - Uses useInteractionPhase hook for interaction state machine
* - Single source of truth for all UI state
* - Explicit phase transitions instead of boolean flags
*/
export function ActiveSession({
plan,
student,
onAnswer,
onEndEarly,
onPause,
onResume,
onComplete,
onTimingUpdate,
onBroadcastStateChange,
isBrowseMode: isBrowseModeProp = false,
browseIndex: browseIndexProp,
onBrowseIndexChange,
teacherControl,
onTeacherControlHandled,
teacherPauseRequest,
onTeacherPauseHandled,
teacherResumeRequest,
onTeacherResumeHandled,
manualPauseRequest,
onManualPauseHandled,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// Check if abacus is docked (to force show submit button)
const { isDockedByUser, requestDock, undock, dock, setDockedValue } = useMyAbacus()
// Sound effects
const { playSound } = usePracticeSoundEffects()
// Compute initial problem from plan for SSR hydration (must be before useInteractionPhase)
const initialProblem = useMemo(() => {
const currentPart = plan.parts[plan.currentPartIndex]
const currentSlot = currentPart?.slots[plan.currentSlotIndex]
if (currentPart && currentSlot?.problem) {
return {
problem: currentSlot.problem,
slotIndex: plan.currentSlotIndex,
partIndex: plan.currentPartIndex,
}
}
return undefined
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
// Interaction state machine - single source of truth for UI state
const {
phase,
attempt,
helpContext,
outgoingAttempt,
canAcceptInput,
showAsCompleted,
showHelpOverlay,
showInputArea,
showFeedback,
inputIsFocused,
isTransitioning,
isPaused,
isSubmitting,
prefixSums,
matchedPrefixIndex,
canSubmit,
shouldAutoSubmit,
ambiguousHelpTermIndex,
loadProblem,
handleDigit,
handleBackspace,
enterHelpMode,
exitHelpMode,
clearAnswer,
setAnswer,
startSubmit,
completeSubmit,
startTransition,
completeTransition,
clearToLoading,
pause,
resume,
} = useInteractionPhase({
initialProblem,
onManualSubmitRequired: () => playSound('womp_womp'),
})
// Notify parent of timing data changes for external timing display
useEffect(() => {
if (onTimingUpdate) {
if (attempt) {
onTimingUpdate({
startTime: attempt.startTime,
accumulatedPauseMs: attempt.accumulatedPauseMs,
})
} else {
onTimingUpdate(null)
}
}
}, [onTimingUpdate, attempt?.startTime, attempt?.accumulatedPauseMs])
// Calculate total progress across all parts (needed for broadcast state)
const totalProblems = useMemo(() => {
return plan.parts.reduce((sum, part) => sum + part.slots.length, 0)
}, [plan.parts])
const completedProblems = useMemo(() => {
let count = 0
for (let i = 0; i < plan.currentPartIndex; i++) {
count += plan.parts[i].slots.length
}
count += plan.currentSlotIndex
return count
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
// Notify parent of broadcast state changes for session observation
useEffect(() => {
if (!onBroadcastStateChange) return
// Helper to extract complexity data from a slot
const extractComplexity = (slot: ProblemSlot | undefined): BroadcastComplexity | undefined => {
if (!slot) return undefined
const bounds = slot.complexityBounds
const trace = slot.problem?.generationTrace
const hasBounds = bounds && (bounds.min !== undefined || bounds.max !== undefined)
const hasCost = trace?.totalComplexityCost !== undefined
if (!hasBounds && !hasCost) return undefined
return {
bounds: hasBounds ? { min: bounds?.min, max: bounds?.max } : undefined,
totalCost: trace?.totalComplexityCost,
stepCount: trace?.steps?.length,
targetSkillName: extractTargetSkillName(slot) ?? undefined,
}
}
// Get current slot's purpose from plan
const currentPart = plan.parts[plan.currentPartIndex]
const slot = currentPart?.slots[plan.currentSlotIndex]
const purpose = slot?.purpose ?? 'focus'
// During transitioning, we show the outgoing (completed) problem, not the incoming one
// But we use the PREVIOUS slot's purpose since we're still showing feedback for it
if (phase.phase === 'transitioning' && outgoingAttempt) {
// During transition, use the previous slot's purpose and complexity
const prevSlotIndex = plan.currentSlotIndex > 0 ? plan.currentSlotIndex - 1 : 0
const prevSlot = currentPart?.slots[prevSlotIndex]
const prevPurpose = prevSlot?.purpose ?? purpose
onBroadcastStateChange({
currentProblem: {
terms: outgoingAttempt.problem.terms,
answer: outgoingAttempt.problem.answer,
},
phase: 'feedback',
studentAnswer: outgoingAttempt.userAnswer,
isCorrect: outgoingAttempt.result === 'correct',
startedAt: attempt?.startTime ?? Date.now(),
purpose: prevPurpose,
complexity: extractComplexity(prevSlot),
currentProblemNumber: completedProblems + 1,
totalProblems,
})
return
}
if (!attempt) {
onBroadcastStateChange(null)
return
}
// Map internal phase to broadcast phase
let broadcastPhase: 'problem' | 'feedback' | 'tutorial'
if (phase.phase === 'helpMode') {
broadcastPhase = 'tutorial'
} else if (phase.phase === 'showingFeedback') {
broadcastPhase = 'feedback'
} else {
broadcastPhase = 'problem'
}
// Determine if answer is correct (only known in feedback phase)
let isCorrect: boolean | null = null
if (phase.phase === 'showingFeedback') {
// Use the result stored in the phase, not calculated from attempt
isCorrect = phase.result === 'correct'
}
onBroadcastStateChange({
currentProblem: {
terms: attempt.problem.terms,
answer: attempt.problem.answer,
},
phase: broadcastPhase,
studentAnswer: attempt.userAnswer,
isCorrect,
startedAt: attempt.startTime,
purpose,
complexity: extractComplexity(slot),
currentProblemNumber: completedProblems + 1,
totalProblems,
})
}, [
onBroadcastStateChange,
attempt?.problem?.terms,
attempt?.problem?.answer,
attempt?.userAnswer,
attempt?.startTime,
phase,
outgoingAttempt,
plan.parts,
plan.currentPartIndex,
plan.currentSlotIndex,
completedProblems,
totalProblems,
])
// Handle teacher abacus control events
useEffect(() => {
if (!teacherControl) return
// Only handle controls during problem-solving phases
const isInputPhase =
phase.phase === 'inputting' ||
phase.phase === 'awaitingDisambiguation' ||
phase.phase === 'helpMode'
if (!isInputPhase || !attempt) {
onTeacherControlHandled?.()
return
}
switch (teacherControl.type) {
case 'set-value':
// Teacher sets the abacus to a specific value
// Update both the docked abacus value AND the answer input
setDockedValue(teacherControl.value)
setAnswer(String(teacherControl.value))
console.log('[ActiveSession] Teacher set abacus value to:', teacherControl.value)
break
case 'show-abacus':
// Request dock with animation (triggers MyAbacus to animate into dock)
if (!isDockedByUser && dock) {
requestDock()
}
console.log('[ActiveSession] Teacher requested to dock abacus')
break
case 'hide-abacus':
// Undock the MyAbacus
if (isDockedByUser) {
undock()
}
console.log('[ActiveSession] Teacher requested to undock abacus')
break
}
// Clear the control after handling
onTeacherControlHandled?.()
}, [
teacherControl,
phase.phase,
attempt,
setAnswer,
setDockedValue,
isDockedByUser,
dock,
requestDock,
undock,
onTeacherControlHandled,
])
// Handle teacher-initiated pause requests
// Use a ref to track if we've handled this request to prevent duplicate handling
const handledPauseRef = useRef(false)
useEffect(() => {
if (!teacherPauseRequest) {
handledPauseRef.current = false
return
}
if (handledPauseRef.current) return
handledPauseRef.current = true
// Pause the session with teacher reason
const newPauseInfo: PauseInfo = {
pausedAt: new Date(),
reason: 'teacher',
teacherMessage: teacherPauseRequest.message,
}
setPauseInfo(newPauseInfo)
pause()
onPause?.(newPauseInfo)
console.log('[ActiveSession] Teacher paused session:', teacherPauseRequest.message)
// Clear the request after handling
onTeacherPauseHandled?.()
}, [teacherPauseRequest, pause, onPause, onTeacherPauseHandled])
// Handle teacher-initiated resume requests
// Use a ref to track if we've handled this request to prevent duplicate handling
const handledResumeRef = useRef(false)
useEffect(() => {
if (!teacherResumeRequest) {
handledResumeRef.current = false
return
}
if (handledResumeRef.current) return
handledResumeRef.current = true
// Resume the session
setPauseInfo(undefined)
lastResumeTimeRef.current = Date.now()
resume()
onResume?.()
console.log('[ActiveSession] Teacher resumed session')
// Clear the request after handling
onTeacherResumeHandled?.()
}, [teacherResumeRequest, resume, onResume, onTeacherResumeHandled])
// Handle manual pause requests from parent (HUD pause button)
const handledManualPauseRef = useRef(false)
useEffect(() => {
if (!manualPauseRequest) {
handledManualPauseRef.current = false
return
}
if (handledManualPauseRef.current) return
handledManualPauseRef.current = true
// Pause the session with manual reason
const newPauseInfo: PauseInfo = {
pausedAt: new Date(),
reason: 'manual',
}
setPauseInfo(newPauseInfo)
pause()
onPause?.(newPauseInfo)
console.log('[ActiveSession] Manual pause triggered from HUD')
// Clear the request after handling
onManualPauseHandled?.()
}, [manualPauseRequest, pause, onPause, onManualPauseHandled])
// Track which help elements have been individually dismissed
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
const [helpPanelDismissed, setHelpPanelDismissed] = useState(false)
// Track when answer is fading out (for dream sequence)
const [answerFadingOut, setAnswerFadingOut] = useState(false)
// Track pause info for displaying in the modal (single source of truth)
const [pauseInfo, setPauseInfo] = useState<PauseInfo | undefined>(undefined)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
// Determine if browse index is controlled
const isControlledBrowseIndex = browseIndexProp !== undefined
const browseIndex = isControlledBrowseIndex ? browseIndexProp : internalBrowseIndex
// Unified setter that handles both controlled and uncontrolled modes
const setBrowseIndex = useCallback(
(index: number | ((prev: number) => number)) => {
const newIndex = typeof index === 'function' ? index(browseIndex) : index
if (isControlledBrowseIndex) {
onBrowseIndexChange?.(newIndex)
} else {
setInternalBrowseIndex(newIndex)
}
},
[browseIndex, isControlledBrowseIndex, onBrowseIndexChange]
)
// Compute current practice position as a linear index
const currentPracticeLinearIndex = useMemo(() => {
return getLinearIndex(plan.parts, plan.currentPartIndex, plan.currentSlotIndex)
}, [plan.parts, plan.currentPartIndex, plan.currentSlotIndex])
// When entering browse mode, initialize browseIndex to current problem
const prevBrowseModeProp = useRef(isBrowseModeProp)
useEffect(() => {
if (isBrowseModeProp && !prevBrowseModeProp.current) {
// Just entered browse mode - set to current practice position
setBrowseIndex(currentPracticeLinearIndex)
}
prevBrowseModeProp.current = isBrowseModeProp
}, [isBrowseModeProp, currentPracticeLinearIndex, setBrowseIndex])
// Track last resume time to reset auto-pause timer after resuming
const lastResumeTimeRef = useRef<number>(0)
const handleDigitWithTimerReset = useCallback(
(digit: string) => {
lastResumeTimeRef.current = Date.now()
handleDigit(digit)
},
[handleDigit]
)
// Reset dismissed states when help context changes (new help session)
useEffect(() => {
if (helpContext) {
setHelpAbacusDismissed(false)
setHelpPanelDismissed(false)
setAnswerFadingOut(false)
}
}, [helpContext])
// Exit help mode when both help elements are dismissed
useEffect(() => {
if (showHelpOverlay && helpAbacusDismissed && helpPanelDismissed) {
exitHelpMode()
}
}, [showHelpOverlay, helpAbacusDismissed, helpPanelDismissed, exitHelpMode])
// Refs for measuring problem widths during animation
const outgoingRef = useRef<HTMLDivElement>(null)
const activeRef = useRef<HTMLDivElement>(null)
// Track problem container height for AbacusDock sizing
const [problemHeight, setProblemHeight] = useState<number | null>(null)
// Measure problem container height with ResizeObserver
// Use useLayoutEffect to run synchronously after DOM mutations (before paint)
// This ensures we get accurate measurements before the browser paints
useLayoutEffect(() => {
const element = activeRef.current
if (!element) return
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
// Use borderBoxSize for more accurate measurement including padding
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height
setProblemHeight(height)
}
})
observer.observe(element)
// Initial measurement - offsetHeight includes padding and border
setProblemHeight(element.offsetHeight)
return () => observer.disconnect()
}, [attempt?.slotIndex, attempt?.partIndex]) // Re-run when problem changes
// Track if we need to apply centering offset (set true when transition starts)
const needsCenteringOffsetRef = useRef(false)
// Store the centering offset value for the animation end
const centeringOffsetRef = useRef(0)
// Spring for problem transition animation
const [trackSpring, trackApi] = useSpring(() => ({
x: 0,
outgoingOpacity: 1,
activeOpacity: 1,
config: { tension: 200, friction: 26 },
}))
// Spring for submit button entrance animation
// Show submit button when: manual submit is required OR abacus is docked (user needs way to submit)
const showSubmitButton = attempt?.manualSubmitRequired || isDockedByUser
const submitButtonSpring = useSpring({
transform: showSubmitButton ? 'translateY(0px)' : 'translateY(60px)',
opacity: showSubmitButton ? 1 : 0,
scale: showSubmitButton ? 1 : 0.8,
config: { tension: 280, friction: 14 },
})
// Apply centering offset before paint to prevent jank
useLayoutEffect(() => {
if (needsCenteringOffsetRef.current && outgoingRef.current) {
const outgoingWidth = outgoingRef.current.offsetWidth
const gap = 32 // 2rem gap
const centeringOffset = (outgoingWidth + gap) / 2
centeringOffsetRef.current = centeringOffset
// Set initial position to compensate for flexbox centering
trackApi.set({
x: centeringOffset,
outgoingOpacity: 1,
activeOpacity: 0,
})
needsCenteringOffsetRef.current = false
// Start fade-in of new problem
trackApi.start({
activeOpacity: 1,
config: { tension: 200, friction: 26 },
})
// Start slide after a brief moment (150ms)
setTimeout(() => {
trackApi.start({
x: -centeringOffset,
outgoingOpacity: 0,
config: { tension: 170, friction: 22 },
onRest: () => {
// Outgoing is now invisible - complete the transition
flushSync(() => {
completeTransition()
})
trackApi.set({ x: 0, outgoingOpacity: 1, activeOpacity: 1 })
},
})
}, 150)
}
}, [outgoingAttempt, trackApi, completeTransition])
const hasPhysicalKeyboard = useHasPhysicalKeyboard()
// Track if keypad was ever shown - once shown, keep it visible
// This prevents the keypad from disappearing if user uses physical keyboard
const keypadWasShownRef = useRef(false)
if (hasPhysicalKeyboard === false) {
keypadWasShownRef.current = true
}
const showOnScreenKeypad = hasPhysicalKeyboard === false || keypadWasShownRef.current
// Get current part and slot from plan
const parts = plan.parts
const currentPartIndex = plan.currentPartIndex
const currentSlotIndex = plan.currentSlotIndex
const currentPart = parts[currentPartIndex] as SessionPart | undefined
const currentSlot = currentPart?.slots[currentSlotIndex] as ProblemSlot | undefined
const sessionHealth = plan.sessionHealth as SessionHealth | null
// Check for session completion
useEffect(() => {
if (currentPartIndex >= parts.length) {
onComplete()
}
}, [currentPartIndex, parts.length, onComplete])
// Initialize problem when slot changes and in loading phase
useEffect(() => {
if (currentPart && currentSlot && phase.phase === 'loading') {
if (!currentSlot.problem) {
throw new Error(
`Problem not pre-generated for slot ${currentSlotIndex} in part ${currentPartIndex}. ` +
'This indicates a bug in session planning - problems should be generated at plan creation time.'
)
}
loadProblem(currentSlot.problem, currentSlotIndex, currentPartIndex)
}
}, [currentPart, currentSlot, currentPartIndex, currentSlotIndex, phase.phase, loadProblem])
// Auto-trigger help when an unambiguous prefix sum is detected
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
// This effect only handles the inputting phase case for unambiguous matches
// DISABLED when abacus is docked - user controls when to submit, help triggers on submit if needed
useEffect(() => {
// Skip auto-help when abacus is docked - user has manual control
if (isDockedByUser) return
// Only handle unambiguous prefix matches in inputting phase
// Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode
if (phase.phase !== 'inputting') return
// For unambiguous matches, trigger immediately
if (matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1) {
const newConfirmedCount = matchedPrefixIndex + 1
if (newConfirmedCount < phase.attempt.problem.terms.length) {
enterHelpMode(newConfirmedCount)
}
}
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode, isDockedByUser])
// Handle when student reaches target value on help abacus
// Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit
const handleTargetReached = useCallback(() => {
if (phase.phase !== 'helpMode' || !helpContext) return
// Step 1: Set the answer to the target value (shows in answer boxes behind abacus)
setAnswer(String(helpContext.targetValue))
// Step 2: After a brief moment, dismiss the help abacus to reveal the answer
setTimeout(() => {
setHelpAbacusDismissed(true)
// Step 3: After abacus fades out (300ms), answer boxes are visible with target value
// Wait 1 second to let user see the result
setTimeout(() => {
// Step 4: Start fading out the answer
setAnswerFadingOut(true)
// Step 5: After fade-out completes (300ms), clear and exit
setTimeout(() => {
clearAnswer()
setAnswerFadingOut(false)
exitHelpMode()
}, 300) // Match fade-out duration
}, 1000) // Show target value in answer boxes for 1 second
}, 600) // Brief pause to see success state on abacus
}, [phase.phase, helpContext, setAnswer, clearAnswer, exitHelpMode])
// Handle value change from the docked abacus
const handleAbacusDockValueChange = useCallback(
(newValue: number) => {
// When the abacus shows the correct answer, set it and auto-submit will trigger
setAnswer(String(newValue))
},
[setAnswer]
)
// Handle submit
const handleSubmit = useCallback(async () => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
if (
phase.phase !== 'inputting' &&
phase.phase !== 'awaitingDisambiguation' &&
phase.phase !== 'helpMode'
) {
return
}
if (!phase.attempt.userAnswer) return
const attemptData = phase.attempt
const answerNum = parseInt(attemptData.userAnswer, 10)
if (Number.isNaN(answerNum)) return
// When abacus is docked and not already in help mode, check if answer is a prefix sum
// If so, trigger help mode instead of submitting (mimic auto-help behavior on submit)
if (isDockedByUser && phase.phase !== 'helpMode') {
// Check if the answer matches a prefix sum (but not the final answer)
const prefixIndex = prefixSums.indexOf(answerNum)
if (prefixIndex >= 0 && prefixIndex < prefixSums.length - 1) {
// Answer matches a prefix sum - enter help mode instead of submitting
const newConfirmedCount = prefixIndex + 1
if (newConfirmedCount < attemptData.problem.terms.length) {
enterHelpMode(newConfirmedCount)
return
}
}
}
// Transition to submitting phase
startSubmit()
// Subtract accumulated pause time to get actual response time
const responseTimeMs = Date.now() - attemptData.startTime - attemptData.accumulatedPauseMs
const isCorrect = answerNum === attemptData.problem.answer
// Record the result
const result: Omit<SlotResult, 'timestamp' | 'partNumber'> = {
slotIndex: attemptData.slotIndex,
problem: attemptData.problem,
studentAnswer: answerNum,
isCorrect,
responseTimeMs,
skillsExercised: attemptData.problem.skillsRequired,
usedOnScreenAbacus: phase.phase === 'helpMode',
incorrectAttempts: 0, // TODO: track this properly
hadHelp: phase.phase === 'helpMode',
}
await onAnswer(result)
// Complete submit with result
completeSubmit(isCorrect ? 'correct' : 'incorrect')
// Wait for feedback display then advance
setTimeout(
() => {
const nextSlotIndex = currentSlotIndex + 1
const nextSlot = currentPart?.slots[nextSlotIndex]
if (nextSlot && currentPart && isCorrect) {
// Has next problem - animate transition
if (!nextSlot.problem) {
throw new Error(
`Problem not pre-generated for slot ${nextSlotIndex} in part ${currentPartIndex}. ` +
'This indicates a bug in session planning - problems should be generated at plan creation time.'
)
}
// Mark that we need to apply centering offset in useLayoutEffect
needsCenteringOffsetRef.current = true
startTransition(nextSlot.problem, nextSlotIndex)
} else {
// End of part or incorrect - clear to loading
clearToLoading()
}
},
isCorrect ? 500 : 1500
)
}, [
phase,
onAnswer,
currentSlotIndex,
currentPart,
currentPartIndex,
startSubmit,
completeSubmit,
startTransition,
clearToLoading,
isDockedByUser,
prefixSums,
enterHelpMode,
])
// Auto-submit when correct answer is entered
useEffect(() => {
if (shouldAutoSubmit) {
handleSubmit()
}
}, [shouldAutoSubmit, handleSubmit])
// Handle keyboard input
useEffect(() => {
if (!hasPhysicalKeyboard || !canAcceptInput) return
const handleKeyDown = (e: KeyboardEvent) => {
// Escape or Delete/Backspace exits help mode when in help mode
if (e.key === 'Escape') {
e.preventDefault()
if (showHelpOverlay) {
exitHelpMode()
}
return
}
if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
if (showHelpOverlay) {
exitHelpMode()
} else {
handleBackspace()
}
return
}
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (/^[0-9]$/.test(e.key)) {
handleDigitWithTimerReset(e.key)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [
hasPhysicalKeyboard,
canAcceptInput,
handleSubmit,
handleDigitWithTimerReset,
handleBackspace,
showHelpOverlay,
exitHelpMode,
])
// Auto-pause when user takes too long on a problem
// Uses mean + 2 standard deviations of response times if we have enough data,
// otherwise defaults to 5 minutes
useEffect(() => {
// Only run auto-pause when actively inputting
if (
phase.phase !== 'inputting' &&
phase.phase !== 'awaitingDisambiguation' &&
phase.phase !== 'helpMode'
) {
return
}
// Don't auto-pause if already paused or no attempt yet
if (isPaused || !attempt) return
// Calculate the threshold and stats from historical results
const { threshold, stats } = calculateAutoPauseInfo(plan.results)
// Calculate remaining time until auto-pause
// After resume, use the resume time as effective start (resets the auto-pause timer)
const effectiveStartTime = Math.max(attempt.startTime, lastResumeTimeRef.current)
const elapsedMs = Date.now() - effectiveStartTime
const remainingMs = threshold - elapsedMs
// Create pause info for auto-timeout
const autoPauseInfo: PauseInfo = {
pausedAt: new Date(),
reason: 'auto-timeout',
autoPauseStats: stats,
}
// If already over threshold, pause immediately
if (remainingMs <= 0) {
setPauseInfo(autoPauseInfo)
pause()
onPause?.(autoPauseInfo)
return
}
// Set timeout to trigger pause when threshold is reached
const timeoutId = setTimeout(() => {
// Update pausedAt to actual pause time
autoPauseInfo.pausedAt = new Date()
setPauseInfo(autoPauseInfo)
pause()
onPause?.(autoPauseInfo)
}, remainingMs)
return () => clearTimeout(timeoutId)
}, [
phase.phase,
isPaused,
attempt?.startTime,
attempt?.accumulatedPauseMs,
plan.results,
pause,
onPause,
])
const handlePause = useCallback(
(info?: PauseInfo) => {
const newPauseInfo: PauseInfo = info ?? {
pausedAt: new Date(),
reason: 'manual',
}
setPauseInfo(newPauseInfo)
pause()
onPause?.(newPauseInfo)
},
[pause, onPause]
)
const handleResume = useCallback(() => {
setPauseInfo(undefined)
lastResumeTimeRef.current = Date.now() // Reset auto-pause timer
resume()
onResume?.()
}, [resume, onResume])
const getHealthColor = (health: SessionHealth['overall']) => {
switch (health) {
case 'good':
return 'green.500'
case 'warning':
return 'yellow.500'
case 'struggling':
return 'red.500'
default:
return 'gray.500'
}
}
const getHealthEmoji = (health: SessionHealth['overall']) => {
switch (health) {
case 'good':
return '🟢'
case 'warning':
return '🟡'
case 'struggling':
return '🔴'
default:
return '⚪'
}
}
if (!currentPart || !attempt) {
return (
<div
data-component="active-session"
data-status="loading"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '300px',
})}
>
<div
className={css({
fontSize: '1.25rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
Loading next problem...
</div>
</div>
)
}
// Browse mode - show the browse view instead of the practice view
if (isBrowseModeProp) {
return (
<BrowseModeView
plan={plan}
browseIndex={browseIndex}
currentPracticeIndex={currentPracticeLinearIndex}
/>
)
}
return (
<div
data-component="active-session"
data-status={isPaused ? 'paused' : 'active'}
data-phase={phase.phase}
data-part-type={currentPart.type}
className={css({
display: 'flex',
flexDirection: 'column',
gap: { base: '0.75rem', md: '1rem' },
padding: { base: '0.5rem', md: '1rem' },
maxWidth: '600px',
margin: '0 auto',
height: '100%', // Fill parent container
overflow: 'hidden', // Prevent overflow
})}
>
{/* Problem display */}
<div
data-section="problem-area"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: { base: '0.75rem', md: '1rem' },
flex: 1, // Take remaining space
minHeight: 0, // Allow shrinking
paddingTop: { base: '1rem', md: '2rem' },
paddingRight: { base: '1rem', md: '2rem' },
paddingBottom: { base: '1rem', md: '1.5rem' },
paddingLeft: { base: '1rem', md: '2rem' },
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '16px',
boxShadow: 'md',
overflow: 'hidden', // Prevent overflow
})}
>
{/* Purpose badge with tooltip */}
{currentSlot && (
<TooltipProvider>
<Tooltip
content={<PurposeTooltipContent slot={currentSlot} />}
side="bottom"
delayDuration={300}
>
<div
data-element="problem-purpose"
data-purpose={currentSlot.purpose}
className={css({
position: 'relative',
padding: '0.25rem 0.75rem',
borderRadius: '20px',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
cursor: 'help',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
_hover: {
transform: 'scale(1.05)',
boxShadow: 'sm',
},
backgroundColor:
currentSlot.purpose === 'focus'
? isDark
? 'blue.900'
: 'blue.100'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.900'
: 'orange.100'
: currentSlot.purpose === 'review'
? isDark
? 'green.900'
: 'green.100'
: isDark
? 'purple.900'
: 'purple.100',
color:
currentSlot.purpose === 'focus'
? isDark
? 'blue.200'
: 'blue.700'
: currentSlot.purpose === 'reinforce'
? isDark
? 'orange.200'
: 'orange.700'
: currentSlot.purpose === 'review'
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'purple.200'
: 'purple.700',
})}
>
{currentSlot.purpose}
</div>
</Tooltip>
</TooltipProvider>
)}
{/* Problem display - centered, with help panel positioned outside */}
<div
data-element="problem-with-help"
className={css({
display: 'flex',
justifyContent: 'center',
width: '100%',
})}
>
{/* Animated track for problem transitions */}
<animated.div
data-element="problem-track"
style={{
display: 'flex',
alignItems: 'flex-start',
transform: trackSpring.x.to((x) => `translateX(${x}px)`),
}}
>
{/* Outgoing problem (slides left during transition) */}
{outgoingAttempt && (
<animated.div
ref={outgoingRef}
data-element="outgoing-problem"
style={{
opacity: trackSpring.outgoingOpacity,
marginRight: '2rem',
position: 'relative' as const,
}}
>
<VerticalProblem
terms={outgoingAttempt.problem.terms}
userAnswer={outgoingAttempt.userAnswer}
isCompleted={true}
correctAnswer={outgoingAttempt.problem.answer}
size="large"
generationTrace={outgoingAttempt.problem.generationTrace}
/>
{/* Feedback stays with outgoing problem */}
<PracticeFeedback
isCorrect={true}
correctAnswer={outgoingAttempt.problem.answer}
className={css({
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
whiteSpace: 'nowrap',
})}
/>
</animated.div>
)}
{/* Current problem */}
<animated.div
ref={activeRef}
data-element="problem-container"
style={{
opacity: trackSpring.activeOpacity,
position: 'relative' as const,
}}
>
{currentPart.format === 'vertical' ? (
<VerticalProblem
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
size="large"
currentHelpTermIndex={helpContext?.termIndex}
needHelpTermIndex={
// Only show "need help?" prompt when not already in help mode
!showHelpOverlay && ambiguousHelpTermIndex >= 0
? ambiguousHelpTermIndex
: undefined
}
rejectedDigit={attempt.rejectedDigit}
helpOverlay={
// Always render overlay when in help mode (for exit transition)
showHelpOverlay && helpContext ? (
<PracticeHelpOverlay
currentValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
columns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString()
.length
)}
onTargetReached={handleTargetReached}
onDismiss={() => {
setHelpAbacusDismissed(true)
clearAnswer()
}}
visible={!helpAbacusDismissed}
/>
) : undefined
}
helpOverlayVisible={showHelpOverlay && !helpAbacusDismissed}
helpOverlayTransitionMs={helpAbacusDismissed ? 300 : 1000}
onHelpOverlayTransitionEnd={clearAnswer}
answerFadingOut={answerFadingOut}
generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/>
) : (
<LinearProblem
terms={attempt.problem.terms}
userAnswer={attempt.userAnswer}
isFocused={inputIsFocused}
isCompleted={showAsCompleted}
correctAnswer={attempt.problem.answer}
isDark={isDark}
detectedPrefixIndex={
matchedPrefixIndex >= 0 && matchedPrefixIndex < prefixSums.length - 1
? matchedPrefixIndex
: undefined
}
/>
)}
{/* Help panel - absolutely positioned to the right of the problem */}
{showHelpOverlay && helpContext && !helpPanelDismissed && (
<div
data-element="help-panel"
className={css({
position: 'absolute',
left: '100%',
top: 0,
marginLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
minWidth: '200px',
maxWidth: '280px',
})}
>
{/* Close button for help panel */}
<button
type="button"
data-action="close-help-panel"
onClick={() => setHelpPanelDismissed(true)}
className={css({
position: 'absolute',
top: '-8px',
right: '-8px',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: isDark ? 'gray.700' : 'gray.200',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderRadius: '50%',
cursor: 'pointer',
zIndex: 10,
_hover: {
backgroundColor: isDark ? 'gray.600' : 'gray.300',
color: isDark ? 'gray.200' : 'gray.700',
},
})}
aria-label="Close help panel"
>
×
</button>
{/* Coach hint */}
{(() => {
const hint = generateCoachHint(
helpContext.currentValue,
helpContext.targetValue
)
if (!hint) return null
return (
<div
data-element="coach-hint"
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.4',
margin: 0,
})}
>
{hint}
</p>
</div>
)
})()}
{/* Decomposition display */}
<DecompositionProvider
startValue={helpContext.currentValue}
targetValue={helpContext.targetValue}
currentStepIndex={0}
abacusColumns={Math.max(
1,
Math.max(helpContext.currentValue, helpContext.targetValue).toString().length
)}
>
<DecompositionSection
className={css({
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
whiteSpace: 'nowrap',
})}
labelClassName={css({
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.25rem',
textTransform: 'uppercase',
})}
contentClassName={css({
fontFamily: 'monospace',
fontSize: '0.875rem',
color: isDark ? 'gray.100' : 'gray.800',
})}
/>
</DecompositionProvider>
</div>
)}
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
{/* Width 100% matches problem width, height matches problem height */}
{currentPart.type === 'abacus' && !showHelpOverlay && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="practice-abacus"
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
)}
</animated.div>
</animated.div>
</div>
{/* Feedback message - only show for incorrect */}
{showFeedback && (
<PracticeFeedback isCorrect={false} correctAnswer={attempt.problem.answer} />
)}
</div>
{/* Input area */}
{showInputArea && !isPaused && (
<div
data-section="input-area"
className={css({
flexShrink: 0, // Don't shrink the input area
})}
>
{/* Submit button - hidden on small screens when keypad is shown (integrated into keypad instead) */}
{!showOnScreenKeypad && (
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '0.75rem',
minHeight: '48px',
overflow: 'hidden',
})}
>
<animated.button
type="button"
data-action="submit"
data-visible={showSubmitButton}
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting || !showSubmitButton}
style={submitButtonSpring}
className={css({
padding: '0.75rem 2rem',
fontSize: '1.125rem',
fontWeight: 'bold',
borderRadius: '8px',
border: 'none',
cursor: !canSubmit || !showSubmitButton ? 'not-allowed' : 'pointer',
backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white',
_hover: {
backgroundColor:
canSubmit && showSubmitButton ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
},
})}
>
Submit
</animated.button>
</div>
)}
{/* On-screen keypad for mobile - includes submit button */}
{showOnScreenKeypad && (
<NumericKeypad
onDigit={handleDigitWithTimerReset}
onBackspace={handleBackspace}
onSubmit={handleSubmit}
disabled={isSubmitting}
currentValue={attempt.userAnswer}
showSubmitButton={showSubmitButton}
/>
)}
</div>
)}
{/* Debug panel - shows current problem details when visual debug mode is on */}
{currentSlot?.problem && (
<ProblemDebugPanel
problem={currentSlot.problem}
slot={currentSlot}
part={currentPart}
partIndex={currentPartIndex}
slotIndex={currentSlotIndex}
userInput={attempt.userAnswer}
phaseName={phase.phase}
/>
)}
{/* Session Paused Modal - rendered here as single source of truth */}
<SessionPausedModal
isOpen={isPaused}
student={student}
session={plan}
pauseInfo={pauseInfo}
onResume={handleResume}
onEndSession={() => onEndEarly('Session ended by user')}
/>
</div>
)
}
export default ActiveSession