diff --git a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx index 525efc15..a59598a6 100644 --- a/apps/web/src/app/practice/[studentId]/PracticeClient.tsx +++ b/apps/web/src/app/practice/[studentId]/PracticeClient.tsx @@ -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(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} /> diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 431b84f5..2f435210 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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 } /** @@ -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': diff --git a/apps/web/src/components/practice/index.ts b/apps/web/src/components/practice/index.ts index 08eb2c0e..bd43a3bd 100644 --- a/apps/web/src/components/practice/index.ts +++ b/apps/web/src/components/practice/index.ts @@ -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'