'use client'; /** * PBL v2 — Workspace shell. * * Three-column layout: stage tree sidebar (left, ~22%), agent chat * (center, ~52%), submission / hints side panel (right, ~26%). * Hidden when `uiPhase` is not 'workspace' — the parent renderer * routes other phases to Hero / Completion. The Workspace itself * doesn't manage navigation between phases; it just renders the * one it's been asked to show. */ import type { PBLMilestone, PBLProjectV2 } from '@/lib/pbl/v2/types'; import Image from 'next/image'; import { Maximize2, Workflow } from 'lucide-react'; import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react'; import { PBLV2Sidebar } from './sidebar'; import { PBLV2AgentTabs } from './agent-tabs'; import { PBLV2SubmissionPanel, type SubmissionEvaluationStatus } from './submission'; import { PBLV2RightPanelTabs } from './right-panel-tabs'; import { shouldShowScenarioBriefing } from './scenario-briefing-gate'; import { cn } from '@/lib/utils/cn'; import { opsApiPath } from '@/lib/ops/api-fetch'; import { useI18n } from '@/lib/hooks/use-i18n'; import type { CSSProperties } from 'react'; import { runOneStream, type StreamDisplayState, type StreamStatus } from './use-instructor-stream'; import type { PBLProjectPatch } from '@/lib/pbl/v2/api/sse'; interface Props { readonly project: PBLProjectV2; readonly onProjectChange: (next: PBLProjectV2) => void; /** Leave the workspace and go back to the Hero, keeping all progress. * Hero then offers "Continue project" (progress intact) + a reset. */ readonly onReturnToHero: () => void; /** True while an Instructor / evaluator stream is in flight (including one * started before this workspace remounted). Threaded to the chat so its * "thinking…" indicator survives a Hero ↔ workspace round trip. */ readonly instructorStreaming: boolean; readonly onInstructorStreamingChange: (active: boolean) => void; /** When provided, render an "expand to fullscreen workspace" button * (inline mode). Absent when rendered inside the immersive overlay. */ readonly onExpand?: () => void; } type PanelSlot = 'sidebar' | 'chat' | 'submission'; type ResizeHandleSide = 'left' | 'right'; type CSSVariableProperties = CSSProperties & Record<`--${string}`, string | number>; const DEFAULT_PANEL_WIDTHS = { sidebar: 22, chat: 52, submission: 26, }; const PANEL_LIMITS = { sidebarMin: 15, sidebarMax: 36, chatMin: 34, submissionMin: 18, submissionMax: 38, }; function streamStatusForEvaluationKind(kind: unknown): StreamStatus { if (kind === 'final') return 'eval-final'; if (kind === 'milestone') return 'eval-milestone'; if (kind === 'task') return 'eval-task'; return 'instructor'; } const PBL_WORKSPACE_THEME = { '--background': 'oklch(0.205 0.055 264)', '--foreground': 'oklch(0.962 0.016 260)', '--card': 'oklch(0.285 0.055 263)', '--card-foreground': 'oklch(0.97 0.014 260)', '--popover': 'oklch(0.265 0.055 263)', '--popover-foreground': 'oklch(0.97 0.014 260)', '--primary': '#9d8cff', '--primary-foreground': 'oklch(0.99 0.005 260)', '--secondary': 'oklch(0.32 0.052 260)', '--secondary-foreground': 'oklch(0.95 0.016 260)', '--muted': 'oklch(0.305 0.046 262)', '--muted-foreground': 'oklch(0.78 0.04 258)', '--accent': 'oklch(0.37 0.07 260)', '--accent-foreground': 'oklch(0.965 0.014 260)', '--destructive': 'oklch(0.66 0.19 25)', '--border': 'oklch(0.74 0.055 262 / 0.22)', '--input': 'oklch(0.68 0.05 262 / 0.3)', '--ring': 'oklch(0.73 0.12 282)', } satisfies CSSVariableProperties; interface CompleteTaskPayload { project?: PBLProjectV2; completedMicrotaskId?: string; milestoneId?: string; milestoneCompleted?: boolean; projectCompleted?: boolean; nextMicrotaskId?: string; } export function PBLV2Workspace({ project, onProjectChange, onReturnToHero, instructorStreaming, onInstructorStreamingChange, onExpand, }: Props) { const rootRef = useRef(null); const [panelWidths, setPanelWidths] = useState(DEFAULT_PANEL_WIDTHS); const [resizingHandle, setResizingHandle] = useState(null); const [submissionEvaluationStatus, setSubmissionEvaluationStatus] = useState(null); const [sceneBusy, setSceneBusy] = useState(false); const [taskBusy, setTaskBusy] = useState(false); const [workspaceStream, setWorkspaceStream] = useState(null); const activeMilestoneIndex = useMemo(() => workspaceActiveMilestoneIndex(project), [project]); // SCENARIO ONLY. Once the learner has ENTERED the scenario (the prep stage is // complete), the right column gains a "scenario briefing" tab beside the // submission panel. `prep` only ever transitions to `completed`, so this // stays true for the rest of the run — through every roleplay stage, the // wrapup, and after returning from the completion page. Non-scenario projects // (and scenario projects still in prep) keep the bare submission panel, so // there is zero change for them. const showScenarioBriefing = useMemo(() => shouldShowScenarioBriefing(project), [project]); // SCENARIO ONLY. The sidebar scene controls all hit the same stateless // task/update endpoint (no LLM, no eval) and replace the local project // with the server-mutated result: // - enter_scenario → prep → first roleplay stage (+ divider) // - continue_handover → consume a staged handover → next stage (+ divider) // There is NO manual beat-advance control: within a roleplay stage, beats // advance ONLY when the engine detects the learner actually did them. // Ordinary projects never render these buttons; the server rejects any // action that is not a scenario stage in the right state. const runSceneAction = useCallback( async (action: 'enter_scenario' | 'continue_handover' | 'complete_act') => { if (sceneBusy) return; setSceneBusy(true); try { const res = await fetch('/api/pbl/v2/task/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project, action }), }); if (!res.ok) return; const data = (await res.json()) as { data?: { project?: PBLProjectV2 }; project?: PBLProjectV2; }; const nextProject = data.data?.project ?? data.project; if (nextProject) onProjectChange(nextProject); } catch { /* transient; the button stays available for retry */ } finally { setSceneBusy(false); } }, [sceneBusy, project, onProjectChange], ); const handleEnterScenario = useCallback(() => runSceneAction('enter_scenario'), [runSceneAction]); const handleContinueHandover = useCallback( () => runSceneAction('continue_handover'), [runSceneAction], ); // ACT MODEL. Finish the current roleplay act. No confirmation: an act may // legitimately hold a single hidden beat that one learner turn satisfies, so // "finished after one message" is a valid path — not something to second-guess. // The button itself only appears once the learner has engaged the scene // (sidebar gates on ≥1 learner message), which is the only guard needed. const handleCompleteAct = useCallback(() => { void runSceneAction('complete_act'); }, [runSceneAction]); const runWorkspaceStream = useCallback( async (startingProject: PBLProjectV2, status: StreamStatus, body: Record) => { setWorkspaceStream({ status, draftAssistant: '', streamCommittedOutput: false }); return runOneStream({ endpoint: status === 'instructor' ? '/api/pbl/v2/open-task' : '/api/pbl/v2/evaluate', body, startingProject, setDraftAssistant: (update) => { setWorkspaceStream((current) => { const base = current && current.status === status ? current : { status, draftAssistant: '', streamCommittedOutput: false }; return { ...base, draftAssistant: update(base.draftAssistant) }; }); }, onPatch: (patch: PBLProjectPatch) => { if (patch.kind === 'message' || patch.kind === 'evaluation') { setWorkspaceStream((current) => current ? { ...current, draftAssistant: '', streamCommittedOutput: true } : current, ); } }, onProjectUpdated: onProjectChange, }); }, [onProjectChange], ); const runEvaluationPhase = useCallback( async ( startingProject: PBLProjectV2, kind: 'task' | 'milestone' | 'final', body: Record, ) => runWorkspaceStream(startingProject, streamStatusForEvaluationKind(kind), body), [runWorkspaceStream], ); const runTaskOpenerPhase = useCallback( async (startingProject: PBLProjectV2) => runWorkspaceStream(startingProject, 'instructor', { project: startingProject, phase: 'setup', }), [runWorkspaceStream], ); const handleCompleteTask = useCallback(async () => { if (taskBusy || instructorStreaming || submissionEvaluationStatus) return; setTaskBusy(true); onInstructorStreamingChange(true); try { const res = await fetch('/api/pbl/v2/task/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project, action: 'complete_pending_task' }), }); if (!res.ok) return; const data = (await res.json()) as CompleteTaskPayload & { data?: CompleteTaskPayload }; const payload: CompleteTaskPayload = data.data ?? data; let nextProject = payload.project; if (!nextProject) return; onProjectChange(nextProject); if (payload.milestoneCompleted && payload.milestoneId) { nextProject = await runEvaluationPhase(nextProject, 'milestone', { project: nextProject, kind: 'milestone', milestoneId: payload.milestoneId, }); } if (payload.projectCompleted) { nextProject = await runEvaluationPhase(nextProject, 'final', { project: nextProject, kind: 'final', }); } if (!payload.milestoneCompleted && payload.nextMicrotaskId) { await runTaskOpenerPhase(nextProject); } } catch { /* transient; the button stays available for retry */ } finally { setTaskBusy(false); setWorkspaceStream(null); onInstructorStreamingChange(false); } }, [ taskBusy, instructorStreaming, submissionEvaluationStatus, onProjectChange, onInstructorStreamingChange, project, runEvaluationPhase, runTaskOpenerPhase, ]); const handleResizeStart = useCallback( (event: ReactMouseEvent, side: ResizeHandleSide) => { event.preventDefault(); const root = rootRef.current; if (!root) return; const rect = root.getBoundingClientRect(); if (rect.width <= 0) return; const startWidths = panelWidths; setResizingHandle(side); const handleMouseMove = (moveEvent: MouseEvent) => { const pointerPercent = ((moveEvent.clientX - rect.left) / rect.width) * 100; setPanelWidths(() => { if (side === 'left') { const pairTotal = startWidths.sidebar + startWidths.chat; const nextSidebar = clamp( pointerPercent, PANEL_LIMITS.sidebarMin, Math.min(PANEL_LIMITS.sidebarMax, pairTotal - PANEL_LIMITS.chatMin), ); return { sidebar: nextSidebar, chat: pairTotal - nextSidebar, submission: startWidths.submission, }; } const pairTotal = startWidths.chat + startWidths.submission; const nextSubmission = clamp( 100 - pointerPercent, PANEL_LIMITS.submissionMin, Math.min(PANEL_LIMITS.submissionMax, pairTotal - PANEL_LIMITS.chatMin), ); return { sidebar: startWidths.sidebar, chat: pairTotal - nextSubmission, submission: nextSubmission, }; }); }; const handleMouseUp = () => { setResizingHandle(null); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }, [panelWidths], ); return (
{/* Scene controls ("finish this act" / enter / continue) are disabled while ANYTHING is generating: character speaking, narrator, evaluation, submission analysis and opener streams all flip `instructorStreaming` (ref-counted across remounts). It is never the learner's turn to advance the scene mid-generation, so gating here stops a click from racing an in-flight stream and corrupting state. */} {showScenarioBriefing ? ( ) : ( )}
); } function WorkspaceTopBar({ project, activeMilestoneIndex, panelWidths, onReturnToHero, onExpand, }: { readonly project: PBLProjectV2; readonly activeMilestoneIndex: number; readonly panelWidths: typeof DEFAULT_PANEL_WIDTHS; readonly onReturnToHero: () => void; readonly onExpand?: () => void; }) { const { t } = useI18n(); return (
OpenMAIC

{project.title}

{/* Breadcrumb doubles as the "back to Hero" affordance: the brand (logo + project name) stays pinned top-left, and the "up" nav lives in the breadcrumb per standard SaaS practice (directional back is not placed in the top-right utility zone). */}
{t('pbl.v2.workspace.progressLabel')}
{project.milestones.map((milestone, index) => { const active = index === activeMilestoneIndex; const completed = milestone.status === 'completed'; return (
{active && (
)}
); })}
{onExpand && ( )}
); } /** Horizontal position (0..1) of the progress dot WITHIN the active * milestone's bar segment, derived from how far the learner is through * that milestone's own microtasks. The first task sits near the head, * the last near the tail (a small margin keeps the dot off the very * edges), evenly spaced in between. A single-task milestone centers the * dot. The count is read off the milestone's OWN `microtasks`, so each * stage uses its own task total — stage A's 2 tasks never get mixed with * stage B's 3. */ function milestoneProgressFraction(milestone: PBLMilestone): number { const EDGE_MARGIN = 0.12; const total = milestone.microtasks.length; if (total <= 1) return 0.5; let index = milestone.microtasks.findIndex( (task) => task.status === 'todo' || task.status === 'in_progress', ); // No open task left (all completed/skipped) → the learner is at the end // of this milestone, so pin the dot to the tail. if (index < 0) index = total - 1; return EDGE_MARGIN + (index / (total - 1)) * (1 - 2 * EDGE_MARGIN); } export function workspaceActiveMilestoneIndex(project: PBLProjectV2): number { const activeIndex = project.milestones.findIndex((milestone) => milestone.status === 'active'); if (activeIndex >= 0) return activeIndex; if (project.status === 'completed') return -1; return project.milestones.length > 0 ? 0 : -1; } function WorkspaceResizeHandle({ side, active, onMouseDown, }: { readonly side: ResizeHandleSide; readonly active: boolean; readonly onMouseDown: (event: ReactMouseEvent, side: ResizeHandleSide) => void; }) { const { t } = useI18n(); return (
onMouseDown(event, side)} >
); } function Panel({ slot, children }: { readonly slot: PanelSlot; readonly children: ReactNode }) { return (
{children}
); } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); }