'use client'; /** * PBL v2 — Workspace sidebar. * * Renders the project roadmap as a milestone tree. Every milestone * can be expanded so learners can inspect the full route; completed * tasks stay crossed out, future tasks stay muted, and the active * task remains highlighted. * * State icons (Lock / Circle / Check / SkipForward) come from * lucide-react to match the rest of OpenMAIC's icon set. * * SCENARIO ONLY — the fixed three-act skeleton (prep → roleplay → wrapup) * is made legible in the roadmap: each act gets a small section label, and * the roleplay act(s) are visually set apart (indented + tinted + a drama * marker) so the learner can tell at a glance that the simulation stretch is * different from the bookend prep/debrief stages. Ordinary projects (no * `project.scenario`) render exactly as before — none of this applies. */ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Check, Circle, CircleDot, Lock, SkipForward, ChevronDown, ChevronRight, ArrowRight, Drama, } from 'lucide-react'; import type { PBLProjectV2, PBLMicrotask, PBLMilestone } from '@/lib/pbl/v2/types'; import { PBL_SIMULATOR_AGENT_ID } from '@/lib/pbl/v2/operations/kernel/progress'; import { cn } from '@/lib/utils/cn'; import { useI18n } from '@/lib/hooks/use-i18n'; /** SCENARIO ONLY. The contextual scene control rendered under a stage. * prep → enter the scene; an active roleplay act → finish it (act model: * the learner ends the continuous scene when ready); a finished stage with a * staged handover → cross to the next stage. There is NO mid-act beat advance. */ type SceneActionKind = 'enter' | 'continue' | 'complete'; /** SCENARIO ONLY. The fixed three-act stage role. */ type ScenarioStage = NonNullable; interface Props { readonly project: PBLProjectV2; /** SCENARIO ONLY. prep → enter the scene (enter_scenario). */ readonly onEnterScenario?: () => void; /** SCENARIO ONLY. A finished stage with a staged handover → next stage * (continue_handover). Only ever shown once the current stage's tasks are * all complete (a handover is staged); there is NO manual beat advance. */ readonly onContinueHandover?: () => void; /** SCENARIO ONLY (act model). The active roleplay act → finish it * (complete_act). Shown once the learner has actually engaged the scene. */ readonly onCompleteAct?: () => void; /** Ordinary PBL. Confirms a task that reached B point and advances it. */ readonly onCompleteTask?: () => void; /** A scene action is in flight (disables the button). */ readonly sceneBusy?: boolean; readonly taskBusy?: boolean; } export function PBLV2Sidebar({ project, onEnterScenario, onContinueHandover, onCompleteAct, onCompleteTask, sceneBusy, taskBusy, }: Props) { const { t } = useI18n(); const activeMilestoneId = useMemo( () => project.milestones.find((m) => m.status === 'active')?.id, [project.milestones], ); const isScenario = !!project.scenario; const pendingHandover = project.pendingHandover && !project.pendingHandover.consumed ? project.pendingHandover : undefined; // ACT MODEL gate: has the learner actually engaged the CURRENT act? The // simulator thread is shared across all roleplay acts, so we must scope to // THIS act's beats — checking "any user message in the thread" would let a // later act be finished without playing it (the first act's messages already // satisfy a thread-wide check). Learner messages carry the beat `microtaskId` // they were sent under, so we match against this milestone's beat ids. const simUserMessages = useMemo( () => project.threads ?.find((t) => t.agentId === PBL_SIMULATOR_AGENT_ID) ?.messages.filter((m) => m.roleType === 'user') ?? [], [project.threads], ); const hasEngagedAct = useCallback( (milestone: PBLMilestone): boolean => { const beatIds = new Set(milestone.microtasks.map((b) => b.id)); return simUserMessages.some((m) => !!m.microtaskId && beatIds.has(m.microtaskId)); }, [simUserMessages], ); const defaultExpandedIds = useMemo(() => sidebarDefaultExpandedMilestoneIds(project), [project]); const [expandedIds, setExpandedIds] = useState>(() => new Set(defaultExpandedIds)); // SCENARIO ONLY. Maps the milestone id that OPENS each three-act section to // its stage, so we render a single section label at every stage boundary // (prep → first roleplay → wrapup). Empty for ordinary projects. const stageSectionStarts = useMemo(() => { const starts = new Map(); if (!isScenario) return starts; let prev: ScenarioStage | undefined; for (const m of project.milestones) { const stage = m.scenarioStage; if (stage && stage !== prev) starts.set(m.id, stage); prev = stage; } return starts; }, [isScenario, project.milestones]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional derived-state sync on milestone changes setExpandedIds((prev) => { const validIds = new Set(project.milestones.map((milestone) => milestone.id)); const next = new Set([...prev].filter((id) => validIds.has(id))); for (const id of defaultExpandedIds) next.add(id); return next; }); }, [defaultExpandedIds, project.milestones]); const totalMicrotasks = useMemo( () => project.milestones.reduce((acc, m) => acc + m.microtasks.length, 0), [project.milestones], ); const completedMicrotasks = useMemo( () => project.milestones.reduce( (acc, m) => acc + m.microtasks.filter((t) => t.status === 'completed').length, 0, ), [project.milestones], ); return ( ); } /** SCENARIO ONLY. A small section header marking a three-act boundary. The * prep & wrapup labels share a neutral look; the roleplay label is set apart * (indented + drama marker + violet) to telegraph the immersive stretch. */ function StageLabel({ stage }: { readonly stage: ScenarioStage }) { const { t } = useI18n(); const labelKey = stage === 'prep' ? 'pbl.v2.sidebar.stagePrep' : stage === 'wrapup' ? 'pbl.v2.sidebar.stageWrapup' : 'pbl.v2.sidebar.stageRoleplay'; if (stage === 'roleplay') { return (
{t(labelKey)}
); } return (
{t(labelKey)}
); } function MilestoneNode({ milestone, index, scenarioStage, active, expanded, onToggle, sceneAction, sceneBusy, pendingTaskCompletionId, onCompleteTask, taskBusy, }: { readonly milestone: PBLMilestone; readonly index: number; /** SCENARIO ONLY — drives the set-apart roleplay treatment. Undefined for * ordinary projects and for non-scenario milestones. */ readonly scenarioStage?: ScenarioStage; readonly active: boolean; readonly expanded: boolean; readonly onToggle: () => void; readonly sceneAction?: { kind: SceneActionKind; onClick: () => void }; readonly sceneBusy?: boolean; readonly pendingTaskCompletionId?: string; readonly onCompleteTask?: () => void; readonly taskBusy?: boolean; }) { const { t } = useI18n(); const isRoleplay = scenarioStage === 'roleplay'; // SCENARIO ONLY — a scenario milestone is an "act"; the user only ever sees // the act, never its inner microtasks. prep/wrapup carry one trivial task; a // roleplay act's beats are HIDDEN CHECKPOINTS (judged in the background, not // shown — the learner is not asked to tick them off). So every scenario // milestone renders as a single flat, non-expandable row. DISPLAY-only: the // microtasks still exist in the data model and advancement is untouched. // Ordinary (non-scenario) projects keep the normal expandable task tree. const flat = scenarioStage !== undefined; const sceneActionLabel: Record = { enter: t('pbl.v2.sidebar.enterScenario'), continue: t('pbl.v2.sidebar.continueScene'), complete: t('pbl.v2.sidebar.completeAct'), }; const stateIcon = (() => { if (milestone.status === 'completed') return ; if (milestone.status === 'active') return ( ); return ( ); })(); return (
{flat ? ( // Single flat row — no expand affordance, no nested task list.
{index + 1} {stateIcon} {milestone.title}
) : ( )} {!flat && expanded && (
    {milestone.microtasks.map((task) => ( ))}
)} {sceneAction && (
)}
); } export function sidebarDefaultExpandedMilestoneIds(project: PBLProjectV2): string[] { if (project.status === 'completed') { return project.milestones.map((milestone) => milestone.id); } const active = project.milestones.find((milestone) => milestone.status === 'active'); return active ? [active.id] : project.milestones[0] ? [project.milestones[0].id] : []; } function MicrotaskRow({ task, showComplete, onCompleteTask, taskBusy, }: { readonly task: PBLMicrotask; readonly showComplete?: boolean; readonly onCompleteTask?: () => void; readonly taskBusy?: boolean; }) { const { t } = useI18n(); const icon = (() => { switch (task.status) { case 'completed': return ; case 'in_progress': return ; case 'skipped': return ; default: return ; } })(); return (
  • {icon} {task.title}
    {showComplete && onCompleteTask && ( )}
  • ); }