feat(classroom): implement teacher-initiated pause and fix manual pause

- Add teacher pause/resume functionality to SessionObserverModal
- Extend PauseInfo interface with 'teacher' reason and teacherMessage
- Handle session-paused/session-resumed WebSocket events in student client
- Update SessionPausedModal UI for teacher-initiated pauses:
  - Show teacher emoji (👩‍🏫) and custom message
  - Disable resume button (only teacher can resume)
- Fix manual pause not showing modal when clicking HUD dropdown
- Add pause columns to session_plans schema (isPaused, pausedAt, pausedBy, pauseReason)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-25 18:43:15 -06:00
parent 2f7002e575
commit ccea0f86ac
15 changed files with 1449 additions and 43 deletions

View File

@@ -0,0 +1,11 @@
-- Custom SQL migration file, put your code below! --
-- Note: These columns were manually added during development.
-- Migration kept for consistency but columns already exist.
-- Columns added to session_plans:
-- is_paused integer DEFAULT 0 NOT NULL
-- paused_at integer
-- paused_by text
-- paused_reason text
SELECT 1;

File diff suppressed because it is too large Load Diff

View File

@@ -302,6 +302,13 @@
"when": 1766406120000,
"tag": "0042_classroom-system-indexes",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1766706763639,
"tag": "0043_add_session_pause_columns",
"breakpoints": true
}
]
}

View File

@@ -18,7 +18,11 @@ import {
useEndSessionEarly,
useRecordSlotResult,
} from '@/hooks/useSessionPlan'
import { useSessionBroadcast, type ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
import {
useSessionBroadcast,
type ReceivedAbacusControl,
type TeacherPauseRequest,
} from '@/hooks/useSessionBroadcast'
import { css } from '../../../../styled-system/css'
interface PracticeClientProps {
@@ -50,6 +54,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
const [browseIndex, setBrowseIndex] = useState(0)
// Teacher abacus control - receives commands from observing teacher
const [teacherControl, setTeacherControl] = useState<ReceivedAbacusControl | null>(null)
// Teacher-initiated pause/resume requests from observing teacher
const [teacherPauseRequest, setTeacherPauseRequest] = useState<TeacherPauseRequest | null>(null)
const [teacherResumeRequest, setTeacherResumeRequest] = useState(false)
// Manual pause request from HUD
const [manualPauseRequest, setManualPauseRequest] = useState(false)
// Session plan mutations
const recordResult = useRecordSlotResult()
@@ -76,9 +85,9 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
return { totalProblems: total, completedProblems: completed }
}, [currentPlan.parts, currentPlan.currentPartIndex, currentPlan.currentSlotIndex])
// Pause/resume handlers - just update HUD state (ActiveSession owns the modal)
// Pause handler - triggers manual pause in ActiveSession
const handlePause = useCallback(() => {
setIsPaused(true)
setManualPauseRequest(true)
}, [])
const handleResume = useCallback(() => {
@@ -125,8 +134,11 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
// Broadcast session state if student is in a classroom
// broadcastState is updated by ActiveSession via the onBroadcastStateChange callback
// onAbacusControl receives control events from observing teacher
// onTeacherPause/onTeacherResume receive pause/resume commands from teacher
useSessionBroadcast(currentPlan.id, studentId, broadcastState, {
onAbacusControl: setTeacherControl,
onTeacherPause: setTeacherPauseRequest,
onTeacherResume: () => setTeacherResumeRequest(true),
})
// Build session HUD data for PracticeSubNav
@@ -212,7 +224,7 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
}}
onAnswer={handleAnswer}
onEndEarly={handleEndEarly}
onPause={handlePause}
onPause={() => setIsPaused(true)}
onResume={handleResume}
onComplete={handleSessionComplete}
onTimingUpdate={setTimingData}
@@ -222,6 +234,12 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
onBrowseIndexChange={setBrowseIndex}
teacherControl={teacherControl}
onTeacherControlHandled={() => setTeacherControl(null)}
teacherPauseRequest={teacherPauseRequest}
onTeacherPauseHandled={() => setTeacherPauseRequest(null)}
teacherResumeRequest={teacherResumeRequest}
onTeacherResumeHandled={() => setTeacherResumeRequest(false)}
manualPauseRequest={manualPauseRequest}
onManualPauseHandled={() => setManualPauseRequest(false)}
/>
</PracticeErrorBoundary>
</main>

View File

@@ -51,11 +51,15 @@ export function SessionObserverModal({
const { requestDock, dock, setDockedValue, isDockedByUser } = useMyAbacus()
// Subscribe to the session's socket channel
const { state, isConnected, isObserving, error, sendControl } = useSessionObserver(
isOpen ? session.sessionId : undefined,
isOpen ? observerId : undefined,
isOpen
)
const { state, isConnected, isObserving, error, sendControl, sendPause, sendResume } =
useSessionObserver(
isOpen ? session.sessionId : undefined,
isOpen ? observerId : undefined,
isOpen
)
// Track if we've paused the session (teacher controls resume)
const [hasPausedSession, setHasPausedSession] = useState(false)
// Ref for measuring problem container height (same pattern as ActiveSession)
const problemRef = useRef<HTMLDivElement>(null)
@@ -98,6 +102,18 @@ export function SessionObserverModal({
sendControl({ type: 'show-abacus' })
}, [dock, isDockedByUser, requestDock, sendControl])
// Pause the student's session
const handlePauseSession = useCallback(() => {
sendPause('Your teacher needs your attention.')
setHasPausedSession(true)
}, [sendPause])
// Resume the student's session
const handleResumeSession = useCallback(() => {
sendResume()
setHasPausedSession(false)
}, [sendResume])
// Two-way sync: When student's abacus changes, sync teacher's docked abacus
useEffect(() => {
if (!isDockedByUser || !state?.studentAnswer) return
@@ -372,29 +388,74 @@ export function SessionObserverModal({
</span>
</div>
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
{/* Teacher controls: pause/resume and dock abaci */}
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
{/* Pause/Resume button */}
{isObserving && (
<button
type="button"
data-action={hasPausedSession ? 'resume-session' : 'pause-session'}
onClick={hasPausedSession ? handleResumeSession : handlePauseSession}
className={css({
padding: '8px 12px',
backgroundColor: hasPausedSession
? isDark
? 'green.700'
: 'green.100'
: isDark
? 'amber.700'
: 'amber.100',
color: hasPausedSession
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'amber.200'
: 'amber.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: {
backgroundColor: hasPausedSession
? isDark
? 'green.600'
: 'green.200'
: isDark
? 'amber.600'
: 'amber.200',
},
})}
>
{hasPausedSession ? '▶️ Resume' : '⏸️ Pause'}
</button>
)}
{/* Dock both abaci button */}
{state && state.phase === 'problem' && (
<button
type="button"
data-action="dock-both-abaci"
onClick={handleDockBothAbaci}
disabled={!isObserving}
className={css({
padding: '8px 12px',
backgroundColor: isDark ? 'blue.700' : 'blue.100',
color: isDark ? 'blue.200' : 'blue.700',
border: 'none',
borderRadius: '6px',
fontSize: '0.8125rem',
fontWeight: 'medium',
cursor: 'pointer',
_hover: { backgroundColor: isDark ? 'blue.600' : 'blue.200' },
_disabled: { opacity: 0.4, cursor: 'not-allowed' },
})}
>
🧮 Dock Abaci
</button>
)}
</div>
</div>
</div>
</>

View File

@@ -213,6 +213,10 @@ function createMockSessionPlanWithProblems(config: {
approvedAt: new Date(Date.now() - 60000),
startedAt: new Date(Date.now() - 30000),
completedAt: null,
isPaused: false,
pausedAt: null,
pausedBy: null,
pauseReason: null,
}
}

View File

@@ -120,6 +120,18 @@ interface ActiveSessionProps {
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
}
/**
@@ -552,6 +564,12 @@ export function ActiveSession({
onBrowseIndexChange,
teacherControl,
onTeacherControlHandled,
teacherPauseRequest,
onTeacherPauseHandled,
teacherResumeRequest,
onTeacherResumeHandled,
manualPauseRequest,
onManualPauseHandled,
}: ActiveSessionProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -781,6 +799,81 @@ export function ActiveSession({
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)

View File

@@ -132,6 +132,10 @@ function createMockSessionPlan(config: {
startedAt: new Date(Date.now() - 10 * 60 * 1000),
completedAt: null,
masteredSkillIds: [],
isPaused: false,
pausedAt: null,
pausedBy: null,
pauseReason: null,
}
}

View File

@@ -52,6 +52,14 @@ const MANUAL_PAUSE_PHRASES = [
'Smart break!',
]
// Phrases for teacher-initiated pause
const TEACHER_PAUSE_PHRASES = [
'Teacher called timeout!',
'Hold on a moment!',
'Quick pause!',
'Wait for your teacher!',
]
// Intl formatters for duration display
const secondsFormatter = new Intl.NumberFormat('en', {
style: 'unit',
@@ -146,7 +154,17 @@ export function SessionPausedModal({
// Pick a random phrase once per pause (stable while modal is open)
const pausePhrase = useMemo(() => {
const phrases = pauseInfo?.reason === 'auto-timeout' ? AUTO_PAUSE_PHRASES : MANUAL_PAUSE_PHRASES
let phrases: string[]
switch (pauseInfo?.reason) {
case 'auto-timeout':
phrases = AUTO_PAUSE_PHRASES
break
case 'teacher':
phrases = TEACHER_PAUSE_PHRASES
break
default:
phrases = MANUAL_PAUSE_PHRASES
}
return phrases[Math.floor(Math.random() * phrases.length)]
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pauseInfo?.pausedAt?.getTime(), pauseInfo?.reason])
@@ -180,6 +198,7 @@ export function SessionPausedModal({
// Determine greeting based on pause reason
const isAutoTimeout = pauseInfo?.reason === 'auto-timeout'
const isTeacherPause = pauseInfo?.reason === 'teacher'
const stats = pauseInfo?.autoPauseStats
return (
@@ -256,7 +275,7 @@ export function SessionPausedModal({
gap: '0.375rem',
})}
>
<span>{isAutoTimeout ? '🤔' : '☕'}</span>
<span>{isTeacherPause ? '👩‍🏫' : isAutoTimeout ? '🤔' : '☕'}</span>
<span>{pausePhrase}</span>
</h2>
{pauseInfo && (
@@ -386,6 +405,33 @@ export function SessionPausedModal({
</div>
)}
{/* Teacher pause message - shown when teacher pauses the session */}
{isTeacherPause && (
<div
data-element="teacher-pause-message"
className={css({
width: '100%',
padding: '1rem',
backgroundColor: isDark ? 'blue.900/30' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
})}
>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'blue.200' : 'blue.700',
textAlign: 'center',
fontWeight: 'medium',
})}
>
{pauseInfo?.teacherMessage ||
'Your teacher paused the session. Please wait for them to resume.'}
</p>
</div>
)}
{/* Progress summary - celebratory */}
<div
className={css({
@@ -474,27 +520,33 @@ export function SessionPausedModal({
<button
type="button"
data-action="resume"
onClick={onResume}
onClick={isTeacherPause ? undefined : onResume}
disabled={isTeacherPause}
style={{
padding: '1.25rem',
fontSize: '1.25rem',
fontWeight: 'bold',
color: '#ffffff',
background: 'linear-gradient(135deg, #22c55e, #16a34a)',
background: isTeacherPause
? 'linear-gradient(135deg, #6b7280, #4b5563)'
: 'linear-gradient(135deg, #22c55e, #16a34a)',
borderRadius: '16px',
border: '3px solid #15803d',
cursor: 'pointer',
border: isTeacherPause ? '3px solid #374151' : '3px solid #15803d',
cursor: isTeacherPause ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
transition: 'all 0.15s ease',
boxShadow: '0 6px 20px rgba(22, 163, 74, 0.5)',
boxShadow: isTeacherPause
? '0 6px 20px rgba(75, 85, 99, 0.3)'
: '0 6px 20px rgba(22, 163, 74, 0.5)',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
width: '100%',
opacity: isTeacherPause ? 0.7 : 1,
}}
>
<span> Keep Going!</span>
<span>{isTeacherPause ? '⏳ Waiting for teacher...' : '▶️ Keep Going!'}</span>
</button>
<button

View File

@@ -50,9 +50,11 @@ export interface PauseInfo {
/** When the pause occurred */
pausedAt: Date
/** Why the session was paused */
reason: 'manual' | 'auto-timeout'
reason: 'manual' | 'auto-timeout' | 'teacher'
/** Auto-pause statistics (only present for auto-timeout) */
autoPauseStats?: AutoPauseStats
/** Teacher's custom message (only present for teacher-initiated pause) */
teacherMessage?: string
}
/**

View File

@@ -340,6 +340,20 @@ export const sessionPlans = sqliteTable(
/** Results for each completed slot */
results: text('results', { mode: 'json' }).notNull().default('[]').$type<SlotResult[]>(),
// ---- Pause State (for teacher observation control) ----
/** Whether the session is currently paused by a teacher */
isPaused: integer('is_paused', { mode: 'boolean' }).notNull().default(false),
/** When the session was paused */
pausedAt: integer('paused_at', { mode: 'timestamp' }),
/** Observer ID (teacher user ID) who paused the session */
pausedBy: text('paused_by'),
/** Optional reason for pausing (e.g., "Let's review this concept together") */
pauseReason: text('paused_reason'),
// ---- Timestamps ----
/** When the plan was created */

View File

@@ -4,7 +4,12 @@ import { useCallback, useEffect, useRef } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { BroadcastState } from '@/components/practice'
import { useStudentPresence } from './useClassroom'
import type { AbacusControlEvent, PracticeStateEvent } from '@/lib/classroom/socket-events'
import type {
AbacusControlEvent,
PracticeStateEvent,
SessionPausedEvent,
SessionResumedEvent,
} from '@/lib/classroom/socket-events'
/**
* Abacus control action received from teacher
@@ -14,12 +19,24 @@ export type ReceivedAbacusControl =
| { type: 'hide-abacus' }
| { type: 'set-value'; value: number }
/**
* Pause request from teacher
*/
export interface TeacherPauseRequest {
/** Optional message from teacher to display on pause screen */
message?: string
}
/**
* Options for useSessionBroadcast hook
*/
export interface UseSessionBroadcastOptions {
/** Callback when an abacus control event is received from teacher */
onAbacusControl?: (control: ReceivedAbacusControl) => void
/** Callback when teacher pauses the session */
onTeacherPause?: (request: TeacherPauseRequest) => void
/** Callback when teacher resumes the session */
onTeacherResume?: () => void
}
/**
@@ -154,6 +171,22 @@ export function useSessionBroadcast(
optionsRef.current?.onAbacusControl?.(control)
})
// Listen for pause events from teacher
socket.on('session-paused', (data: SessionPausedEvent) => {
console.log('[SessionBroadcast] Received session-paused:', data)
if (data.sessionId !== sessionId) return
optionsRef.current?.onTeacherPause?.({ message: data.message })
})
// Listen for resume events from teacher
socket.on('session-resumed', (data: SessionResumedEvent) => {
console.log('[SessionBroadcast] Received session-resumed:', data)
if (data.sessionId !== sessionId) return
optionsRef.current?.onTeacherResume?.()
})
return () => {
console.log('[SessionBroadcast] Cleaning up socket connection')
socket.disconnect()

View File

@@ -2,7 +2,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { AbacusControlEvent, PracticeStateEvent } from '@/lib/classroom/socket-events'
import type {
AbacusControlEvent,
PracticeStateEvent,
SessionPausedEvent,
SessionResumedEvent,
} from '@/lib/classroom/socket-events'
/**
* Control actions a teacher can send to a student's practice session abacus
@@ -67,6 +72,10 @@ interface UseSessionObserverResult {
stopObserving: () => void
/** Send a control action to the student's abacus */
sendControl: (action: SessionAbacusControlAction) => void
/** Pause the student's session with optional message */
sendPause: (message?: string) => void
/** Resume the student's session */
sendResume: () => void
}
/**
@@ -213,6 +222,41 @@ export function useSessionObserver(
[isConnected, sessionId]
)
// Send pause command to student's session
const sendPause = useCallback(
(message?: string) => {
if (!socketRef.current || !isConnected || !sessionId) {
console.warn('[SessionObserver] Cannot send pause - not connected or no sessionId')
return
}
const event: SessionPausedEvent = {
sessionId,
reason: 'teacher',
message,
}
socketRef.current.emit('session-pause', event)
console.log('[SessionObserver] Sent session-pause:', { message })
},
[isConnected, sessionId]
)
// Send resume command to student's session
const sendResume = useCallback(() => {
if (!socketRef.current || !isConnected || !sessionId) {
console.warn('[SessionObserver] Cannot send resume - not connected or no sessionId')
return
}
const event: SessionResumedEvent = {
sessionId,
}
socketRef.current.emit('session-resume', event)
console.log('[SessionObserver] Sent session-resume')
}, [isConnected, sessionId])
return {
state,
isConnected,
@@ -220,5 +264,7 @@ export function useSessionObserver(
error,
stopObserving,
sendControl,
sendPause,
sendResume,
}
}

View File

@@ -179,6 +179,12 @@ export interface ObserverJoinedEvent {
export interface SessionPausedEvent {
sessionId: string
reason: string
/** Optional message from teacher to show on pause screen */
message?: string
}
export interface SessionResumedEvent {
sessionId: string
}
/**
@@ -306,6 +312,7 @@ export interface ClassroomServerToClientEvents {
'abacus-control': (data: AbacusControlEvent) => void
'observer-joined': (data: ObserverJoinedEvent) => void
'session-paused': (data: SessionPausedEvent) => void
'session-resumed': (data: SessionResumedEvent) => void
// Session status events (classroom channel - for teacher's active sessions view)
'session-started': (data: SessionStartedEvent) => void
@@ -336,6 +343,8 @@ export interface ClassroomClientToServerEvents {
// Observer controls
'tutorial-control': (data: TutorialControlEvent) => void
'abacus-control': (data: AbacusControlEvent) => void
'session-pause': (data: SessionPausedEvent) => void
'session-resume': (data: SessionResumedEvent) => void
// Skill tutorial broadcasts (from student client to classroom channel)
'skill-tutorial-state': (data: SkillTutorialStateEvent) => void

View File

@@ -844,6 +844,20 @@ export function initializeSocketServer(httpServer: HTTPServer) {
}
)
// Session Observation: Pause command from observer (teacher pauses student's session)
socket.on('session-pause', (data: { sessionId: string; reason: string; message?: string }) => {
console.log('[Socket] session-pause:', data.sessionId, data.message)
// Forward pause command to student's client
io!.to(`session:${data.sessionId}`).emit('session-paused', data)
})
// Session Observation: Resume command from observer (teacher resumes student's session)
socket.on('session-resume', (data: { sessionId: string }) => {
console.log('[Socket] session-resume:', data.sessionId)
// Forward resume command to student's client
io!.to(`session:${data.sessionId}`).emit('session-resumed', data)
})
// Skill Tutorial: Broadcast state from student to classroom (for teacher observation)
// The student joins the classroom channel and emits their tutorial state
socket.on(