diff --git a/frontend/src/platform/CourseView.jsx b/frontend/src/platform/CourseView.jsx
new file mode 100644
index 0000000..6530875
--- /dev/null
+++ b/frontend/src/platform/CourseView.jsx
@@ -0,0 +1,214 @@
+import { useState, useEffect, useCallback } from 'react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { api } from './api'
+import styles from './Platform.module.css'
+
+const ACTIVITY_LABELS = {
+ lesson: 'Lesson', demonstration: 'Demonstration', knowledge_check: 'Knowledge Check',
+ guided_exercise: 'Guided Exercise', lab: 'Lab', reflection: 'Reflection',
+ assessment: 'Assessment', capstone: 'Capstone',
+}
+
+// Phase 6A §6 — structured course learning, nested inside "My Programs" (not a second portal).
+// Local drill-down state only (course -> module -> activity), same "no router" convention as
+// the rest of the Platform Shell (see PlatformShell.jsx). Separate from PracticeTab — this is
+// the guided course path, Practice stays the free-form scenario list it always was.
+export default function CourseView({ enrollmentId, onLaunchLab, onClose }) {
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(null)
+ const [openCourse, setOpenCourse] = useState(null)
+ const [openModule, setOpenModule] = useState(null)
+ const [openActivity, setOpenActivity] = useState(null)
+
+ const load = useCallback(() => {
+ api.courses(enrollmentId).then(setData).catch((e) => setError(e.message))
+ }, [enrollmentId])
+ useEffect(load, [load])
+
+ if (error) return
{error}
+ if (!data) return Loading…
+
+ const course = openCourse && data.courses.find((c) => c.course_id === openCourse)
+ const mod = course && openModule && course.modules.find((m) => m.module_id === openModule)
+ const activity = mod && openActivity && mod.activities.find((a) => a.activity_id === openActivity)
+
+ const crumb = (label, onClick) =>
+
+ if (activity) {
+ return (
+
+
+ {crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null); setOpenActivity(null) })}
+ {crumb(course.title, () => setOpenActivity(null))}
+
+
+
+ )
+ }
+
+ if (mod) {
+ return (
+
+
+ {crumb('← All courses', () => { setOpenCourse(null); setOpenModule(null) })}
+ {crumb(course.title, () => setOpenModule(null))}
+
+
{mod.title}
+ {mod.description &&
{mod.description}
}
+
+
+ {mod.activities.map((a) => (
+
setOpenActivity(a.activity_id)} />
+ ))}
+
+
+ )
+ }
+
+ if (course) {
+ return (
+
+
+ {crumb('← All courses', () => setOpenCourse(null))}
+
+
{course.title}
+ {course.description &&
{course.description}
}
+ {course.objectives?.length > 0 && (
+
+ {course.objectives.map((o, i) => - {o}
)}
+
+ )}
+
+ {course.modules.length === 0 ? (
+
No modules published for this course yet.
+ ) : course.modules.map((m) => (
+
!m.locked && setOpenModule(m.module_id)} />
+ ))}
+
+
+ )
+ }
+
+ return (
+
+
+ {onClose && crumb('← Back to program', onClose)}
+
+
ROOT_ Courses
+ {data.resume && (
+
+ Resume: {data.courses.find((c) => c.course_id === data.resume.course_id)?.title}
+
+ )}
+ {data.courses.map((c) => (
+
!c.locked && setOpenCourse(c.course_id)} />
+ ))}
+
+ )
+}
+
+function statusPill(status) {
+ const cls = status === 'ready' ? styles.pillOk : status === 'partial' ? styles.pillWarn : styles.pillMuted
+ return {status}
+}
+
+function CourseRow({ course, onOpen }) {
+ return (
+
+
+
+
+ {statusPill(course.status)}
+ {course.locked && locked}
+
+
+ {!course.locked && course.modules.length > 0 && (
+
+ )}
+
+ )
+}
+
+function ModuleRow({ mod, onOpen }) {
+ return (
+
+
+
+ {mod.complete ? complete : statusPill(mod.status)}
+ {mod.locked && locked}
+
+
+ )
+}
+
+function ModuleMeta({ mod }) {
+ if (!mod.objectives?.length && !mod.estimated_duration_minutes) return null
+ return (
+
+ {mod.estimated_duration_minutes &&
Estimated time: ~{mod.estimated_duration_minutes} min
}
+ {mod.objectives?.length > 0 && (
+
{mod.objectives.map((o, i) => - {o}
)}
+ )}
+
+ )
+}
+
+function ActivityRow({ activity, onOpen }) {
+ return (
+
+
+ {activity.complete ? done : activity.estimated_duration_minutes ? ~{activity.estimated_duration_minutes} min : null}
+
+ )
+}
+
+function ActivityDetail({ activity, onLaunchLab, onChange }) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ const markComplete = async () => {
+ setBusy(true); setError(null)
+ try { await api.completeActivity(activity.activity_id); onChange() }
+ catch (e) { setError(e.message) } finally { setBusy(false) }
+ }
+
+ return (
+
+
+
{activity.title}
+ {activity.complete && complete}
+
+
{ACTIVITY_LABELS[activity.activity_type] || activity.activity_type}
+ {activity.estimated_duration_minutes && ` · ~${activity.estimated_duration_minutes} min`}
+
+ {activity.body ? (
+
+ {activity.body}
+
+ ) : (
+
No content authored for this activity yet.
+ )}
+
+ {error &&
{error}
}
+
+
+ {activity.activity_type === 'lab' && !activity.complete && (
+
+ )}
+ {activity.completion_behavior === 'manual_marker' && !activity.complete && (
+
+ )}
+ {activity.activity_type === 'capstone' && (
+
Continue via the Capstone tab to start or resume this assessment.
+ )}
+
+
+ )
+}
diff --git a/frontend/src/platform/ProgramsTab.jsx b/frontend/src/platform/ProgramsTab.jsx
index cdf6246..aed3fc7 100644
--- a/frontend/src/platform/ProgramsTab.jsx
+++ b/frontend/src/platform/ProgramsTab.jsx
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { api } from './api'
import styles from './Platform.module.css'
+import CourseView from './CourseView'
const POLL_MS = 2500
@@ -69,6 +70,7 @@ function ProgramCard({ program, onChange }) {
const [envStatus, setEnvStatus] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
+ const [showCourses, setShowCourses] = useState(false)
const pollRef = useRef(null)
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
@@ -97,6 +99,19 @@ function ProgramCard({ program, onChange }) {
const p = program.progress
const nextLabel = program.next_activity?.type === 'capstone' ? 'RootNode Capstone' : program.next_activity?.scenario_id
+ // Phase 6A §6 — "View Courses" opens the structured course/module/activity tree for this
+ // enrollment, right here inline (My Programs -> ROOT_ -> Course -> Module -> Activity), no
+ // separate portal. "Continue Learning" keeps its original Phase 5B behavior unchanged
+ // (straight to the disposable Lab) — CourseView's own Launch Lab buttons call the exact same
+ // handleContinue, so there is still only one launch path underneath both entry points.
+ if (showCourses) {
+ return (
+
+ setShowCourses(false)} />
+
+ )
+ }
+
return (
@@ -116,7 +131,10 @@ function ProgramCard({ program, onChange }) {
)}
{program.effective_status === 'completed' ? (
-
Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.
+ <>
+
Completed {program.completed_at ? new Date(program.completed_at).toLocaleDateString() : ''}.
+
+ >
) : program.blocked_reason ? (
{BLOCKED_MESSAGES[program.blocked_reason] || program.blocked_reason}
@@ -129,9 +147,12 @@ function ProgramCard({ program, onChange }) {
{envStatus && ['requested', 'provisioning'].includes(envStatus.status) ? (
Starting your lab…
) : (
-
+
+
+
+
)}
>
)}
diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js
index 66016a9..642e9fe 100644
--- a/frontend/src/platform/api.js
+++ b/frontend/src/platform/api.js
@@ -124,4 +124,8 @@ export const api = {
programs: () => fetch('/api/student/programs').then(json),
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
+
+ // ── Phase 6A: course/module/activity content ─────────────────────────────────────────────
+ courses: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}/courses`).then(json),
+ completeActivity: (activityId) => mutate(`/api/student/activities/${activityId}/complete`),
}