Files
openmaic/OpenMAIC/components/scene-renderers/pbl-renderer.tsx
2026-08-16 14:58:47 +08:00

542 lines
23 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { createPortal } from 'react-dom';
import { Maximize2, Minimize2 } from 'lucide-react';
import { motion } from 'motion/react';
import type { PBLContent, StageMode } from '@/lib/types/stage';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import { resolvePBLContent, upgradeLegacyPBLConfigToProjectV2 } from '@/lib/pbl/legacy/read';
import { normalizeProjectRuntime } from '@/lib/pbl/v2/operations/kernel/progress';
import { transitionProjectUiPhase } from '@/lib/pbl/v2/operations/kernel/runtime-events';
import { useStageStore } from '@/lib/store/stage';
import { cn } from '@/lib/utils/cn';
import { PBLV2Hero } from './pbl/v2/hero';
import { PBLV2Workspace } from './pbl/v2/workspace';
import { PBLV2Completion } from './pbl/v2/completion';
import { rectsEqual, type LayoutRect } from './pbl/v2/host-rect';
import { useI18n } from '@/lib/hooks/use-i18n';
const IMMERSIVE_LAUNCH_DURATION_SECONDS = 0.45;
// The one-time Hero → workspace launch gets a slower, more deliberate
// reveal; every later manual expand uses the snappy default above. The
// even ease (vs. the front-loaded launch ease) spreads the motion across
// the whole launch so the grow, the workspace fade-in and the backdrop
// dim progress together and the page behind fades away gradually.
const HERO_LAUNCH_EXPAND_DURATION_SECONDS = 1.3;
const HERO_LAUNCH_EXPAND_EASE = [0.4, 0, 0.2, 1] as const;
const IMMERSIVE_EXIT_DURATION_SECONDS = 0.4;
const IMMERSIVE_LAUNCH_EASE = [0.16, 1, 0.3, 1] as const;
const IMMERSIVE_EXIT_EASE = [0.4, 0, 0.2, 1] as const;
interface PBLRendererProps {
readonly content: PBLContent;
readonly mode: StageMode;
readonly sceneId: string;
}
export function PBLRenderer({ content, mode: _mode, sceneId }: PBLRendererProps) {
const { t } = useI18n();
const resolvedProjectV2 = useMemo(() => {
// null is treated like absent: stored scenes predating projectV2 validation
// may carry an explicit null and must keep falling back to the legacy path.
const resolved = resolvePBLContent(content);
if (resolved.kind === 'v2') return resolved.projectV2;
if (resolved.kind === 'legacy') {
return upgradeLegacyPBLConfigToProjectV2(resolved.projectConfig);
}
return null;
}, [content]);
if (resolvedProjectV2) {
return (
<PBLV2Container
sceneId={sceneId}
projectV2={resolvedProjectV2}
onProjectV2Change={(next) => {
useStageStore.getState().updateScene(sceneId, {
content: {
...content,
projectV2: next,
},
});
}}
/>
);
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>{t('pbl.emptyProject')}</p>
</div>
);
}
function PBLV2Container({
sceneId,
projectV2,
onProjectV2Change,
}: {
readonly sceneId: string;
readonly projectV2: PBLProjectV2;
readonly onProjectV2Change: (next: PBLProjectV2) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
// Web-fullscreen ("expanded") vs docked. The workspace renders as a
// SINGLE persistent instance for the whole workspace phase — expand /
// collapse only animates its frame's rect, it never remounts. That is
// what keeps the chat's scroll position and any in-flight Instructor
// stream alive across the toggle (a remount would reset the scroll and
// drop the "thinking…" indicator).
const [expanded, setExpanded] = useState(false);
// One-shot request from the Hero to auto-expand on first launch. The
// layer consumes it on mount so a later return to the workspace (e.g.
// from Completion) stays docked instead of re-expanding.
const [autoExpand, setAutoExpand] = useState(false);
// The web-fullscreen frame is portaled OUT of the scene subtree. It must
// live inside whatever element is natively fullscreened — OpenMAIC fullscreens
// the stage via stageRef.requestFullscreen(), and the browser only paints the
// fullscreened element's own subtree, so a frame portaled to <body> would go
// blank during native fullscreen. Default to <body> (classroom is an
// immersive, full-viewport route, so a fixed overlay there already covers
// everything); follow the active fullscreen element while native fullscreen
// is on. NOTE: this never reads/writes OpenMAIC code — it only chooses this
// component's own portal host.
const [fsRoot, setFsRoot] = useState<HTMLElement | null>(() =>
typeof document !== 'undefined'
? ((document.fullscreenElement as HTMLElement | null) ?? document.body)
: null,
);
// True while the browser is in native (OS) fullscreen — OpenMAIC enters it
// on the stage via `stageRef.requestFullscreen()`. In that mode OpenMAIC's
// own exit affordance (its bottom control bar) is hidden for PBL, so the
// workspace must offer its own exit button (see the layer).
const [nativeFullscreen, setNativeFullscreen] = useState(false);
// How many workspace streams are in flight. Lifted to this container — which
// stays mounted across Hero ↔ workspace — because the workspace layer (and the
// chat that owns each stream's React state) unmounts when the learner returns
// to the Hero. The in-flight `run` keeps executing and reports here, so coming
// back mid-stream shows the "thinking…" indicator again instead of a silent
// chat. Transient by design: a full page reload ends the streams and resets
// this together, so there's no stuck indicator.
//
// A COUNT, not a boolean: streams can overlap (e.g. an Instructor chat turn
// plus a submission evaluation). With a single boolean the first stream to
// settle would clear the flag while the other is still running, re-enabling
// the Hero reset mid-stream — and that late write would clobber the reset.
// Counting keeps "busy" true until ALL streams have settled. Each stream
// reports exactly one `true` on start and one `false` on settle (in a
// `finally`), so the count stays balanced; `Math.max(0, …)` is a defensive
// floor against an unmatched decrement.
const [activeStreamCount, setActiveStreamCount] = useState(0);
const instructorStreaming = activeStreamCount > 0;
const handleInstructorStreamingChange = useCallback((active: boolean) => {
setActiveStreamCount((count) => Math.max(0, active ? count + 1 : count - 1));
}, []);
const { runtimeProject, changed } = useMemo(() => {
const next = structuredClone(projectV2);
return {
runtimeProject: next,
changed: normalizeProjectRuntime(next),
};
}, [projectV2]);
useEffect(() => {
if (changed) onProjectV2Change(runtimeProject);
}, [changed, runtimeProject, onProjectV2Change]);
// Keep the portal host in sync with native fullscreen so the workspace
// renders inside the fullscreened stage instead of an orphaned <body>.
useEffect(() => {
const sync = () => {
const el = document.fullscreenElement as HTMLElement | null;
setFsRoot(el ?? document.body);
setNativeFullscreen(el != null);
};
sync();
document.addEventListener('fullscreenchange', sync);
return () => document.removeEventListener('fullscreenchange', sync);
}, []);
const handleLaunchReady = useCallback(
(ready: PBLProjectV2) => {
// Do NOT set `expanded` here: the layer mounts DOCKED and the reveal is
// driven by flipping `expanded` to true on the next frame (see the layer),
// so the Hero→workspace grow runs through the reliable prop-change
// animation path instead of framer's mount `initial` (which React
// StrictMode's mount/remount skips).
setAutoExpand(true);
onProjectV2Change(ready);
},
[onProjectV2Change],
);
// Return from the workspace to the Hero, keeping all progress intact (only
// `uiPhase` changes). Resetting `expanded`/`autoExpand` is REQUIRED: the
// workspace layer unmounts on Hero, and leaving `expanded` true would make
// the next entry (Continue / Start) mount already-fullscreen, swallowing the
// 1.3s launch reveal. Clearing them sends the next launch through the same
// docked → fullscreen grow as a first launch.
const handleReturnToHero = useCallback(() => {
setExpanded(false);
setAutoExpand(false);
onProjectV2Change(transitionProjectUiPhase(runtimeProject, 'hero'));
}, [runtimeProject, onProjectV2Change]);
// Project writes coming FROM the workspace subtree (Instructor / evaluator /
// submission streams, optimistic edits). A stream that started in the
// workspace can resolve AFTER the learner stepped back to the Hero — we let
// it finish in the background so progress isn't lost. Its project clone still
// carries `uiPhase: 'workspace'`, so writing it verbatim would yank the
// learner back into the workspace. Read the LIVE phase (not a captured one —
// this may be invoked by an already-unmounted stream) and, while the learner
// is on the Hero, keep them there. Explicit navigation (Continue / Completion
// CTA / reset) goes through `onProjectV2Change` directly and is unaffected.
const handleWorkspaceProjectChange = useCallback(
(next: PBLProjectV2) => {
const liveContent = useStageStore.getState().scenes.find((s) => s.id === sceneId)?.content as
| PBLContent
| undefined;
if (liveContent?.projectV2?.uiPhase === 'hero' && next.uiPhase !== 'hero') {
onProjectV2Change({ ...next, uiPhase: 'hero' });
return;
}
onProjectV2Change(next);
},
[sceneId, onProjectV2Change],
);
const content = (() => {
switch (runtimeProject.uiPhase) {
case 'workspace':
case 'completed':
// Both rendered by the portaled single-instance layer below, so the
// web-fullscreen frame (and its expanded state + controls) carries
// across the workspace → completion transition instead of dropping
// completion back into the small docked host.
return null;
case 'generating':
case 'hero':
default:
return (
<PBLV2Hero
sceneId={sceneId}
project={runtimeProject}
onProjectChange={onProjectV2Change}
onLaunchReady={handleLaunchReady}
instructorStreaming={instructorStreaming}
/>
);
}
})();
return (
<div ref={hostRef} className="h-full w-full">
{content}
{(runtimeProject.uiPhase === 'workspace' || runtimeProject.uiPhase === 'completed') &&
typeof document !== 'undefined' &&
createPortal(
<PBLV2WorkspaceLayer
project={runtimeProject}
onProjectChange={handleWorkspaceProjectChange}
onReturnToHero={handleReturnToHero}
instructorStreaming={instructorStreaming}
onInstructorStreamingChange={handleInstructorStreamingChange}
hostRef={hostRef}
expanded={expanded}
autoExpand={autoExpand}
nativeFullscreen={nativeFullscreen}
onExpandedChange={setExpanded}
onAutoExpandConsumed={() => setAutoExpand(false)}
/>,
// Portal to a STABLE host (document.body) for the normal case so the
// Hero→workspace launch reveal survives React's (StrictMode) mount /
// remount — a state-valued host made framer drop the one-time
// `initial` animation. Only follow the natively-fullscreened element
// while native fullscreen is actually on (it never coincides with the
// launch), so OpenMAIC's native fullscreen still shows the workspace.
nativeFullscreen ? (fsRoot ?? document.body) : document.body,
)}
</div>
);
}
function measureHostRect(hostRef: RefObject<HTMLDivElement | null>): LayoutRect {
const rect = hostRef.current?.getBoundingClientRect();
if (rect) {
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
}
if (typeof window === 'undefined') return { left: 0, top: 0, width: 0, height: 0 };
return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
}
/**
* The single, persistent web-fullscreen frame for the workspace AND the
* completion phases. It is portaled to <body> and `position: fixed`, so
* it can cover the viewport when expanded while the React-tree position
* stays stable — meaning the inner <PBLV2Workspace> (and its chat /
* stream state) is never remounted by an expand/collapse toggle.
*
* Hosting both phases here is what lets the fullscreen state survive the
* workspace → completion transition: the frame stays mounted, only its
* inner content swaps, so the learner keeps the same docked/expanded view
* (and its controls) instead of completion dropping back to a small box.
*
* - docked: the frame is positioned over the host (scene) box, with no
* drop-shadow/backdrop, so it looks identical to the previous inline
* layout.
* - expanded: the frame animates to the viewport with a backdrop, and
* shows the collapse (Minimize) control.
*/
function PBLV2WorkspaceLayer({
project,
onProjectChange,
onReturnToHero,
instructorStreaming,
onInstructorStreamingChange,
hostRef,
expanded,
autoExpand,
nativeFullscreen,
onExpandedChange,
onAutoExpandConsumed,
}: {
readonly project: PBLProjectV2;
readonly onProjectChange: (next: PBLProjectV2) => void;
readonly onReturnToHero: () => void;
readonly instructorStreaming: boolean;
readonly onInstructorStreamingChange: (active: boolean) => void;
readonly hostRef: RefObject<HTMLDivElement | null>;
readonly expanded: boolean;
readonly autoExpand: boolean;
readonly nativeFullscreen: boolean;
readonly onExpandedChange: (next: boolean) => void;
readonly onAutoExpandConsumed: () => void;
}) {
const { t } = useI18n();
const isCompleted = project.uiPhase === 'completed';
const [hostRect, setHostRect] = useState<LayoutRect>(() => measureHostRect(hostRef));
// Animate ONLY the expand/collapse toggle (and the launch auto-expand).
// Plain host-rect tracking while docked must apply instantly, otherwise
// the frame would lag behind window/sidebar resizes.
const prevExpandedRef = useRef(expanded);
const isToggle = prevExpandedRef.current !== expanded;
useEffect(() => {
prevExpandedRef.current = expanded;
});
// Keep the docked frame aligned with the host (scene) box across window
// resizes, sidebar toggles and scrolls.
useEffect(() => {
const host = hostRef.current;
let raf = 0;
let last: LayoutRect | null = null;
// Dirty-checked apply: only push React state when the rect actually
// changed, so the per-frame poll below stays free while the box is still.
const sync = () => {
const next = measureHostRect(hostRef);
if (!last || !rectsEqual(last, next)) {
last = next;
setHostRect(next);
}
};
sync();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(sync) : null;
if (observer && host) observer.observe(host);
window.addEventListener('resize', sync);
window.addEventListener('scroll', sync, true);
// Side-panel toggles re-CENTER the height-constrained 16:9 host box without
// resizing it: the box only MOVES, so ResizeObserver / resize / scroll never
// fire and the position:fixed docked frame would stay put — leaking the
// (correctly reflowed) box beside it. While docked, also poll the rect every
// animation frame (a cheap getBoundingClientRect + the dirty-check above, so
// React state changes only when the box truly moves) so the frame tracks the
// slide. Not needed while expanded — the frame is fullscreen, not
// host-aligned — so the loop is skipped (and torn down) in that mode.
if (!expanded) {
const tick = () => {
sync();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
}
return () => {
observer?.disconnect();
window.removeEventListener('resize', sync);
window.removeEventListener('scroll', sync, true);
if (raf) cancelAnimationFrame(raf);
};
}, [hostRef, expanded]);
// Hero launch reveal. The layer mounts DOCKED; on the next frame we flip
// `expanded` to true so the grow runs through the SAME prop-change animation
// as manual maximize (reliable, and unaffected by React StrictMode's
// mount/remount — unlike framer's mount `initial`, which it skips). Keep
// `autoExpand` (slow 1.3s Hero timing) until the reveal has played, then
// consume it so later manual expands use the snappy default.
useEffect(() => {
if (!autoExpand) return;
const raf = requestAnimationFrame(() => onExpandedChange(true));
const consume = window.setTimeout(
onAutoExpandConsumed,
HERO_LAUNCH_EXPAND_DURATION_SECONDS * 1000 + 120,
);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(consume);
};
// Re-run when `autoExpand` flips true so the reveal fires whether the flag
// is already set at mount (the normal case) or arrives just after; the
// callbacks are effectively stable for this one-shot.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoExpand]);
// Lock background scroll only while the workspace owns the viewport.
useEffect(() => {
if (!expanded) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [expanded]);
const dockedStyle = {
left: hostRect.left,
top: hostRect.top,
width: hostRect.width,
height: hostRect.height,
borderRadius: 8,
};
const fullscreenStyle = {
left: 0,
top: 0,
width: '100vw',
height: '100dvh',
borderRadius: 0,
};
const animateFrame = isToggle || autoExpand;
// `autoExpand` is true only on the first Hero launch render → that one
// time gets the slower, evenly-paced reveal (geometry + the workspace's
// own fade-in + the backdrop dim all share this transition, so the page
// behind fades away gradually across the whole launch). Manual maximize
// keeps the snappy default.
const expandDuration = autoExpand
? HERO_LAUNCH_EXPAND_DURATION_SECONDS
: IMMERSIVE_LAUNCH_DURATION_SECONDS;
const expandEase = autoExpand ? HERO_LAUNCH_EXPAND_EASE : IMMERSIVE_LAUNCH_EASE;
const transition = animateFrame
? {
duration: expanded ? expandDuration : IMMERSIVE_EXIT_DURATION_SECONDS,
ease: expanded ? expandEase : IMMERSIVE_EXIT_EASE,
}
: { duration: 0 };
return (
<>
<motion.div
className={cn(
'pointer-events-none fixed inset-0 bg-background/80 backdrop-blur-sm',
// Docked stays a z-auto stacking context (see the frame note); it is
// invisible while docked anyway. Expanded dims the whole page on top.
expanded ? 'z-[110]' : 'isolate',
)}
initial={false}
animate={{ opacity: expanded ? 1 : 0 }}
transition={transition}
/>
<motion.div
className={cn(
'fixed overflow-hidden bg-background text-foreground',
// Docked must behave like ordinary in-page content: NO positive
// z-index. Header dropdowns portal to <body>, but their Radix popper
// wrapper is z-auto (the `z-50` lives on the inner content, trapped by
// the wrapper's transform). A positive z here (even z-1) would paint
// over that wrapper and clip the menu. `isolate` gives the docked
// layer a z-auto stacking context that still covers all in-app scene
// content (it outranks the static app root) yet sits below the
// later-mounted dropdown portal. Expanded is a full takeover on top.
expanded ? 'z-[120]' : 'isolate',
expanded && 'shadow-[0_24px_90px_rgba(0,0,0,0.38)] ring-1 ring-white/10',
)}
initial={false}
animate={{ ...(expanded ? fullscreenStyle : dockedStyle), opacity: 1 }}
transition={transition}
>
{/* Native (OS) fullscreen takes over the top-right slot: OpenMAIC's
own exit bar is hidden for PBL, so this is the only click target
to leave true fullscreen (Esc still works, handled by OpenMAIC).
`document.exitFullscreen()` triggers OpenMAIC's own fullscreenchange
cleanup (keyboard unlock etc.) — we never touch OpenMAIC code. The
violet, borderless-ish treatment mirrors OpenMAIC's native control
so it reads differently from the neutral web-fullscreen buttons. */}
{nativeFullscreen ? (
<button
type="button"
onClick={() => {
document.exitFullscreen?.().catch(() => {});
}}
className="absolute right-4 top-4 z-40 flex h-8 w-8 items-center justify-center rounded-md border border-violet-300/35 bg-violet-500/20 text-violet-100 shadow-sm backdrop-blur transition-colors hover:bg-violet-500/30"
aria-label={t('pbl.v2.workspace.exitNativeFullscreen')}
title={t('pbl.v2.workspace.exitNativeFullscreen')}
>
<Minimize2 className="h-4 w-4" />
</button>
) : (
<>
{expanded && (
<button
type="button"
onClick={() => onExpandedChange(false)}
className="absolute right-4 top-4 z-40 flex h-8 w-8 items-center justify-center rounded-md border border-white/15 bg-background/75 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted"
aria-label={t('pbl.v2.workspace.exitFullscreen')}
title={t('pbl.v2.workspace.exitFullscreen')}
>
<Minimize2 className="h-4 w-4" />
</button>
)}
{/* The Workspace carries its own in-topbar expand control, so the
layer only needs to supply the docked → fullscreen affordance
for Completion (which has no topbar of its own). */}
{!expanded && isCompleted && (
<button
type="button"
onClick={() => onExpandedChange(true)}
className="absolute right-4 top-4 z-40 flex h-8 w-8 items-center justify-center rounded-md border border-white/15 bg-background/75 text-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted"
aria-label={t('pbl.v2.workspace.enterFullscreen')}
title={t('pbl.v2.workspace.enterFullscreen')}
>
<Maximize2 className="h-4 w-4" />
</button>
)}
</>
)}
<div className="relative z-10 h-full w-full">
{isCompleted ? (
<PBLV2Completion
project={project}
onBack={() => onProjectChange(transitionProjectUiPhase(project, 'workspace'))}
/>
) : (
<PBLV2Workspace
project={project}
onProjectChange={onProjectChange}
onReturnToHero={onReturnToHero}
instructorStreaming={instructorStreaming}
onInstructorStreamingChange={onInstructorStreamingChange}
onExpand={expanded || nativeFullscreen ? undefined : () => onExpandedChange(true)}
/>
)}
</div>
</motion.div>
</>
);
}