Files
rootnode-academy/frontend/src/components/ProgressiveHints.jsx
T
ced4568 d4db8f99f5 feat(student): add learning tabs and Lab hint/assessment UX
StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted
unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice,
Assessments (available/active-with-live-countdown/history), Capstone (locked checklist ->
unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard).

Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has
stripped hints (detected via scenario.hints being undefined but hint_count present - standalone
dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner
shows a live countdown and completion count whenever a Platform assessment is running,
rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
2026-08-18 14:35:59 -05:00

92 lines
4.5 KiB
React

import { useState, useEffect, useCallback } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import styles from './ScenarioPanel.module.css'
// Phase 3B progressive hints — used when the Gateway has stripped the full `hints` array from
// the scenario payload (the multi-student, Platform-mediated deployment; see
// platform/gateway/src/server.js's GET /lab/:token/api/scenarios/:scenarioId). Hints are
// fetched one at a time from the Platform API, server-enforced sequential (can't skip to hint
// 3), and every reveal is recorded against the student's attempt — none of that is possible
// with the old fully-preloaded array this replaces for this deployment mode.
//
// In standalone/single-container dev mode there's no Gateway rewrite, so `scenario.hints` is
// still the full array and ScenarioPanel never renders this component at all — see the
// `hintCount != null` check at the call site.
export default function ProgressiveHints({ scenarioId, totalHints }) {
const [revealed, setRevealed] = useState([]) // [{level, title, body, command}]
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(null)
const [assessmentHintPolicy, setAssessmentHintPolicy] = useState(null) // null = not in an assessment; 'free'|'disabled'|'flagged'
// A scenario/environment change resets local reveal state — the server is still the source
// of truth (GET /attempt would show the real highest_hint_level), this is just what's
// rendered locally this session. Also checks whether this scenario is part of an active
// Platform assessment, purely to show the policy indicator pre-emptively (the server enforces
// the policy regardless, on every reveal call — this is UX, not the security boundary).
useEffect(() => {
setRevealed([]); setError(null); setAssessmentHintPolicy(null)
fetch('/api/assessments/active').then((r) => r.json()).then((d) => {
if (d.active && d.scenarios?.some((s) => s.scenario_id === scenarioId)) setAssessmentHintPolicy(d.hint_policy)
}).catch(() => {})
}, [scenarioId])
const revealNext = useCallback(async () => {
setLoading(true); setError(null)
try {
const r = await fetch(`api/scenarios/${scenarioId}/hint`, { method: 'POST' })
const d = await r.json()
if (!r.ok) { setError(d.message || d.error || 'Could not reveal a hint.'); return }
setRevealed((h) => [...h, { level: d.level, ...d.hint }])
} catch {
setError('Could not reach the platform to reveal a hint.')
} finally {
setLoading(false)
}
}, [scenarioId])
function copyCmd(cmd, idx) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(cmd).then(() => { setCopiedIdx(idx); setTimeout(() => setCopiedIdx(null), 1800) })
}
}
if (totalHints === 0) return <div className={styles.noHints}>No hints available for this scenario.</div>
if (assessmentHintPolicy === 'disabled' && revealed.length === 0) {
return <div className={styles.noHints}>Hints Disabled — this assessment does not allow hints.</div>
}
return (
<div>
{assessmentHintPolicy === 'flagged' && <div className={styles.setupNote} style={{ marginBottom: '0.75rem' }}>Using a hint will be recorded.</div>}
{revealed.map((hint, i) => (
<div key={i} className={styles.hintCard}>
<div className={styles.hintHeader}>
<div className={styles.hintLeft}>
<span className={styles.hintNum}>Hint {hint.level}</span>
<span className={styles.hintTitle}>{hint.title}</span>
</div>
</div>
<div className={styles.hintBody}>
<p className={styles.hintText}><ReactMarkdown remarkPlugins={[remarkGfm]}>{hint.body}</ReactMarkdown></p>
{hint.command && (
<div className={styles.cmdBlock}>
<pre className={styles.cmdPre}>{hint.command}</pre>
<button className={styles.copyBtn} onClick={() => copyCmd(hint.command, i)}>{copiedIdx === i ? '✓ Copied' : 'Copy'}</button>
</div>
)}
</div>
</div>
))}
{error && <div className={styles.validateError}>⚠ {error}</div>}
{revealed.length < totalHints && (
<button className={styles.setupBtn} disabled={loading} onClick={revealNext}>
{loading ? 'Revealing…' : `Reveal Hint ${revealed.length + 1} of ${totalHints}`}
</button>
)}
</div>
)
}