'use client'; /** * ClassroomSurface — the classroom, wherever it is mounted. * * This is the body `/classroom/[id]` has always had: the load pipeline, the * generation run it follows and the `Stage` dispatch under `ThemeProvider` / * `MediaStageProvider`. It moved out of the route file for exactly one reason * — the Pro workspace's third pane hosts the REAL classroom, not a preview and * not an iframe, so both surfaces must run the same code rather than two * copies that drift. * * `variant` is only layout/load-context: `page` fills the viewport and treats * a course that cannot be found as terminal; `pane` fills its column and runs * a bounded availability probe because a newly linked course may be committed * shortly afterward. Neither host accepts conversation/session state. A * classroom's lifecycle is keyed only by its course id; document and manifest * data then converge in place as writers update them. * * The reference (live deployment) additionally runs non-owner visitor * hydration, a transport-persistence UI fence and a background uploader; all * three depend on server-side machinery this workspace does not have, so they * are dropped and the load follows the ordinary path * (`app/classroom/[id]/page.tsx`). The stage-meta sidecar is still consulted: * both variants gate the run's Retry on ownership, and the standalone page * also applies its viewer-specific edit access. */ import { Stage } from '@/components/stage'; import { ThemeProvider } from '@/lib/hooks/use-theme'; import { useStageStore } from '@/lib/store'; import { useSettingsStore } from '@/lib/store/settings'; import { claimStageSceneLoadToken, isCurrentStageSceneLoadToken } from '@/lib/store/stage'; import { useEffect, useRef, useState, useCallback } from 'react'; import { useNarrationAdoption } from '@/lib/audio/use-narration-adoption'; import { createLogger } from '@/lib/logger'; import { MediaStageProvider } from '@/lib/contexts/media-stage-context'; import { useI18n } from '@/lib/hooks/use-i18n'; import { FileQuestion, Loader2 } from 'lucide-react'; import Link from 'next/link'; import { useAgentRegistry } from '@/lib/orchestration/registry/store'; import { defaultClassroomLoadDeps, runClassroomLoad } from '@/lib/classroom/load-classroom'; import { paneAvailabilityRetryDelay, resolveClassroomSurfaceView, } from '@/lib/classroom/progressive-load-policy'; import { useClassroomSession } from '@/lib/classroom/use-classroom-session'; import { applySceneDeepLink } from '@/lib/classroom/scene-deep-link'; import { useRunCourse } from '@/lib/generation-run-client/use-run-course'; import { pendingSceneFailureKey } from '@/lib/generation-run-client/failure-message'; import { PblActivityProvider } from '@/components/scene-renderers/pbl/v3/activity-provider'; const log = createLogger('Classroom'); type ClassroomLoadOutcome = 'loaded' | 'unavailable' | 'absent' | 'failed' | 'cancelled'; const LOAD_UNAVAILABLE_ERROR = 'load-unavailable'; // stage_link can become visible shortly before its document. Probe only that // explicit availability gap, with a small bounded backoff; media conversion // and ordinary failures never enter this schedule. export function ClassroomSurface(props: { classroomId: string; variant?: 'page' | 'pane' }) { return ( ); } function ClassroomSurfaceContent({ classroomId, variant = 'page', }: { readonly classroomId: string; readonly variant?: 'page' | 'pane'; }) { const { loadFromStorage } = useStageStore(); const loadedClassroomId = useStageStore((s) => s.stage?.id ?? null); const { t } = useI18n(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [loadUnavailable, setLoadUnavailable] = useState(false); /** * The load resolved and no source has this course. A TERMINAL state, kept * separate from `error`: an error offers a retry, and there is nothing here * to retry. * * The copy it renders is deliberately the SAME whether the course was * deleted or never existed. */ const [notFound, setNotFound] = useState(false); const activeClassroomIdRef = useRef(null); const loadEpochRef = useRef(0); const { mayGenerate, refreshOwnership } = useClassroomSession({ classroomId, variant }); const loadClassroom = useCallback( async (isEffectCurrent: () => boolean): Promise => { const loadToken = claimStageSceneLoadToken(); const isCurrent = () => isEffectCurrent() && isCurrentStageSceneLoadToken(loadToken); try { const loadResult = await runClassroomLoad({ classroomId, loadToken, isCurrent, loadFromStorage: async (id, token) => { await loadFromStorage(id, token); // `?scene=` opens the page at that scene (links from exported // decks). Applied before the rest of the load so the opening // scene's media is the one hydrated first. The pane lives inside // the workspace URL, so only the standalone page reads it. if (variant === 'page' && isCurrent()) { applySceneDeepLink(window.location.search, id, useStageStore); } }, getCurrentStage: () => useStageStore.getState().stage, loadRestoredMediaTasks: defaultClassroomLoadDeps.loadRestoredMediaTasks, applyRestoredMediaTasks: (restored) => defaultClassroomLoadDeps.applyRestoredMediaTasks(restored, isCurrent), discardRestoredMediaTasks: defaultClassroomLoadDeps.discardRestoredMediaTasks, loadLegacyAgentFallbacks: defaultClassroomLoadDeps.loadLegacyAgentFallbacks, commitMigratedAgentConfigs: defaultClassroomLoadDeps.commitMigratedAgentConfigs, applyGeneratedAgents: defaultClassroomLoadDeps.applyGeneratedAgents, getSettings: () => useSettingsStore.getState(), getAgent: (id) => useAgentRegistry.getState().getAgent(id), agentsReady: defaultClassroomLoadDeps.agentsReady, restoreAgentSelection: defaultClassroomLoadDeps.restoreAgentSelection, setError, setLoading, log, }); if (!isCurrent()) return 'cancelled'; // Positive absence only: the course is gone, invalid, or never // existed. Other failures stay on the error/retry path so we never // claim "not found" without a positive answer (#1450). if (loadResult.outcome === 'absent') { if (variant === 'page') { setNotFound(true); } // Inside the workspace the pane treats a miss as the bounded // availability gap (stage_link can land before the document). return 'absent'; } if (loadResult.outcome === 'cancelled') return 'cancelled'; if (loadResult.outcome === 'failed') return 'failed'; // Defensive: a "ready" load that somehow left the wrong course in the // store still must not become not-found. if (useStageStore.getState().stage?.id !== classroomId) { if (variant === 'pane') return 'unavailable'; setLoadUnavailable(true); setError(LOAD_UNAVAILABLE_ERROR); setLoading(false); return 'failed'; } return 'loaded'; } catch (error) { log.error('Failed to load classroom:', error); if (isCurrent()) { setLoadUnavailable(false); setError(error instanceof Error ? error.message : 'Failed to load classroom'); setLoading(false); } return isCurrent() ? 'failed' : 'cancelled'; } }, [classroomId, loadFromStorage, variant], ); const retryClassroom = useCallback(() => { const loadEpoch = loadEpochRef.current + 1; loadEpochRef.current = loadEpoch; const isCurrent = () => activeClassroomIdRef.current === classroomId && loadEpochRef.current === loadEpoch; setError(null); setLoadUnavailable(false); setNotFound(false); setLoading(true); void loadClassroom(isCurrent).then((outcome) => { if (!isCurrent()) return; if (outcome === 'loaded') { refreshOwnership(isCurrent); return; } if (variant === 'pane' && (outcome === 'unavailable' || outcome === 'absent')) { setLoading(false); if (outcome === 'absent') { setNotFound(true); } else { setLoadUnavailable(true); setError(LOAD_UNAVAILABLE_ERROR); } } }); }, [classroomId, loadClassroom, refreshOwnership, variant]); useEffect(() => { let cancelled = false; const loadEpoch = loadEpochRef.current + 1; loadEpochRef.current = loadEpoch; activeClassroomIdRef.current = classroomId; const isCurrent = () => !cancelled && activeClassroomIdRef.current === classroomId && loadEpochRef.current === loadEpoch; // Reset loading state on course switch to unmount Stage during transition, // preventing stale data from syncing back to the new course /* eslint-disable react-hooks/set-state-in-effect -- Course switch must hide stale Stage before async load */ setLoading(true); setError(null); setLoadUnavailable(false); setNotFound(false); /* eslint-enable react-hooks/set-state-in-effect */ let retryTimer: ReturnType | null = null; let availabilityAttempt = 0; /** Last pane gap reason — exhaustion must not claim not-found after a load error. */ let lastGap: 'absent' | 'unavailable' | null = null; // Asked only AFTER a document load succeeds, and again after every later // one, mirroring the page route. The load is what brings a course into the // server store the first time it is opened, so asking beforehand asks about // a course whose ownership row does not exist yet: the 404 that comes back // would lock its genuine author out of generation and of every retry // control for the rest of the mount. The gate stays closed until an answer // arrives, so asking again can only ever open it for someone entitled to it. const loadUntilAvailable = async () => { if (!isCurrent()) return; // A previous pane attempt may have observed a transient read failure. // Clear only its presentation before retrying; do not raise `loading` // again, so an already mounted classroom never flashes away. if (variant === 'pane') setError(null); const outcome = await loadClassroom(isCurrent); if (!isCurrent()) return; if (outcome === 'absent' || outcome === 'unavailable') { lastGap = outcome; if (variant === 'pane') { const delay = paneAvailabilityRetryDelay(availabilityAttempt); availabilityAttempt += 1; if (delay !== null) { retryTimer = setTimeout(loadUntilAvailable, delay); return; } setLoading(false); if (lastGap === 'unavailable') { setLoadUnavailable(true); setError(LOAD_UNAVAILABLE_ERROR); } else { setNotFound(true); } return; } } // The document is now loaded, so the sidecar has something to say about // this course. Absence and load failures must not establish ownership. if (outcome === 'loaded') { refreshOwnership(isCurrent); } }; void loadUntilAvailable(); return () => { cancelled = true; if (loadEpochRef.current === loadEpoch) { loadEpochRef.current += 1; } if (activeClassroomIdRef.current === classroomId) { activeClassroomIdRef.current = null; } if (retryTimer) clearTimeout(retryTimer); }; }, [classroomId, loadClassroom, refreshOwnership, variant]); // Narration written before this application stored media server-side is a // derived key that only this browser can resolve. Both classroom surfaces // mount this, so a course opened through the workbench pane converges its // narration exactly as the standalone page does. useNarrationAdoption(classroomId, { ready: !loading && !error, mayGenerate }); // A course a server-side generation run produces: the classroom follows the // run (its scenes, media and pauses) and sends it Retry. const runCourse = useRunCourse({ classroomId, ready: !loading && !error }); // The pending scene's failure card shows the first outline not yet produced; // the run's guidance belongs there only when that is the scene it stopped at. const pendingOutlineId = useStageStore((s) => s.generatingOutlines[0]?.id); const failureKey = pendingSceneFailureKey(runCourse.failure, pendingOutlineId); const view = resolveClassroomSurfaceView({ variant, loading, error, notFound, loadedClassroomId, classroomId, }); return (
{view === 'loading' ? (

{t('common.loadingClassroom')}

) : view === 'not-found' ? ( // Checked BEFORE `error`, and it renders no retry: the sources have // all answered, and running the same lookups again cannot change // the answer. One message for "deleted" and for "never existed" — // see the state's declaration.

{t('classroom.notFound')}

{t('classroom.notFoundDesc')}

{t('classroom.backToHome')}
) : view === 'error' ? (

{loadUnavailable ? ( t('classroom.loadUnavailable') ) : ( <> {t('common.errorPrefix')} {error} )}

{loadUnavailable ? (

{t('classroom.loadUnavailableDesc')}

) : null}
) : ( )}
); }