fix: sync pause state between modal and ActiveSession

When the auto-pause modal was dismissed via the Resume button, the modal
would hide but input would remain blocked. This was because:

1. PracticeClient has isPaused state (controls modal visibility)
2. ActiveSession has internal phase.phase='paused' (controls input acceptance)

When the modal's Resume button was clicked, only PracticeClient's state
was updated, leaving ActiveSession stuck in 'paused' phase.

Fix: Add sessionRef prop to ActiveSession that exposes resume/pause
handlers, allowing PracticeClient to trigger ActiveSession's internal
resume when the modal is dismissed.

Long-term: Consider moving the modal inside ActiveSession so there's
a single source of truth for pause state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-13 08:06:47 -06:00
parent 8802418fe5
commit 55e5c121f1
3 changed files with 45 additions and 2 deletions

View File

@ -1,10 +1,11 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import {
ActiveSession,
type ActiveSessionHandle,
type AttemptTimingData,
PracticeErrorBoundary,
PracticeSubNav,
@ -38,6 +39,11 @@ interface PracticeClientProps {
export function PracticeClient({ studentId, player, initialSession }: PracticeClientProps) {
const router = useRouter()
// Ref to control ActiveSession's pause/resume imperatively
// This is needed because the modal is rendered here but needs to trigger
// ActiveSession's internal resume() when dismissed
const sessionRef = useRef<ActiveSessionHandle | null>(null)
// Track pause state locally (controlled by callbacks from ActiveSession)
// Never auto-pause - session continues where it left off on load/reload
const [isPaused, setIsPaused] = useState(false)
@ -78,6 +84,10 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
}, [])
const handleResume = useCallback(() => {
// IMPORTANT: Must call sessionRef.current?.resume() to actually resume
// ActiveSession's internal state. Just setting isPaused=false only hides
// the modal but leaves input blocked.
sessionRef.current?.resume()
setIsPaused(false)
setPauseInfo(undefined)
}, [])
@ -178,6 +188,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
hideHud={true}
sessionRef={sessionRef}
/>
</PracticeErrorBoundary>
</main>

View File

@ -107,6 +107,16 @@ export interface AttemptTimingData {
accumulatedPauseMs: number
}
/**
* Imperative handle for controlling the session from parent
*/
export interface ActiveSessionHandle {
/** Resume the session (call this when external resume trigger occurs) */
resume: () => void
/** Pause the session (call this when external pause trigger occurs) */
pause: () => void
}
interface ActiveSessionProps {
plan: SessionPlan
studentName: string
@ -124,6 +134,8 @@ interface ActiveSessionProps {
hideHud?: boolean
/** Called with timing data when it changes (for external timing display) */
onTimingUpdate?: (timing: AttemptTimingData | null) => void
/** Ref to get imperative handle for controlling the session */
sessionRef?: React.MutableRefObject<ActiveSessionHandle | null>
}
/**
@ -582,6 +594,7 @@ export function ActiveSession({
onComplete,
hideHud = false,
onTimingUpdate,
sessionRef,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@ -1096,6 +1109,25 @@ export function ActiveSession({
onResume?.()
}, [resume, onResume])
// Expose imperative handle for parent to control pause/resume
// This is needed because the modal is rendered in the parent and needs
// to trigger the internal resume() when dismissed
// IMPORTANT: We expose the raw `resume`/`pause` functions, NOT `handleResume`/`handlePause`
// which would call `onResume`/`onPause` callbacks and cause an infinite loop
useEffect(() => {
if (sessionRef) {
sessionRef.current = {
resume,
pause,
}
}
return () => {
if (sessionRef) {
sessionRef.current = null
}
}
}, [sessionRef, resume, pause])
const getHealthColor = (health: SessionHealth['overall']) => {
switch (health) {
case 'good':

View File

@ -9,7 +9,7 @@
*/
export { ActiveSession } from './ActiveSession'
export type { AttemptTimingData } from './ActiveSession'
export type { ActiveSessionHandle, AttemptTimingData } from './ActiveSession'
export { ContinueSessionCard } from './ContinueSessionCard'
// Hooks
export { useHasPhysicalKeyboard, useIsTouchDevice } from './hooks/useDeviceDetection'