Files

625 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<HTMLDivElement>(null);
const [panelWidths, setPanelWidths] = useState(DEFAULT_PANEL_WIDTHS);
const [resizingHandle, setResizingHandle] = useState<ResizeHandleSide | null>(null);
const [submissionEvaluationStatus, setSubmissionEvaluationStatus] =
useState<SubmissionEvaluationStatus | null>(null);
const [sceneBusy, setSceneBusy] = useState(false);
const [taskBusy, setTaskBusy] = useState(false);
const [workspaceStream, setWorkspaceStream] = useState<StreamDisplayState | null>(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<string, unknown>) => {
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<string, unknown>,
) => 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 (
<div
ref={rootRef}
className={cn(
'grid h-full w-full overflow-hidden bg-background text-foreground ring-1 ring-indigo-100/[0.16]',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.10),0_24px_72px_rgba(42,50,95,0.20)]',
'bg-[radial-gradient(circle_at_18%_5%,rgba(119,102,255,0.20),transparent_31%),radial-gradient(circle_at_86%_10%,rgba(20,184,166,0.17),transparent_29%),radial-gradient(circle_at_52%_105%,rgba(96,165,250,0.10),transparent_34%),linear-gradient(180deg,#182542_0%,#111c34_48%,#162743_100%)]',
)}
data-pbl-workspace="true"
style={{
...PBL_WORKSPACE_THEME,
gridTemplateRows: '58px minmax(0, 1fr)',
gridTemplateColumns: `${panelWidths.sidebar}fr 6px ${panelWidths.chat}fr 6px ${panelWidths.submission}fr`,
}}
>
<WorkspaceTopBar
project={project}
activeMilestoneIndex={activeMilestoneIndex}
panelWidths={panelWidths}
onReturnToHero={onReturnToHero}
onExpand={onExpand}
/>
<Panel slot="sidebar">
{/* 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. */}
<PBLV2Sidebar
project={project}
onEnterScenario={handleEnterScenario}
onContinueHandover={handleContinueHandover}
onCompleteAct={handleCompleteAct}
onCompleteTask={handleCompleteTask}
sceneBusy={sceneBusy || instructorStreaming}
taskBusy={taskBusy}
/>
</Panel>
<WorkspaceResizeHandle
side="left"
active={resizingHandle === 'left'}
onMouseDown={handleResizeStart}
/>
<Panel slot="chat">
<PBLV2AgentTabs
project={project}
onProjectChange={onProjectChange}
submissionEvaluationStatus={submissionEvaluationStatus}
instructorStreaming={instructorStreaming}
onInstructorStreamingChange={onInstructorStreamingChange}
externalStream={workspaceStream}
/>
</Panel>
<WorkspaceResizeHandle
side="right"
active={resizingHandle === 'right'}
onMouseDown={handleResizeStart}
/>
<Panel slot="submission">
{showScenarioBriefing ? (
<PBLV2RightPanelTabs
project={project}
onProjectChange={onProjectChange}
onEvaluationStatusChange={setSubmissionEvaluationStatus}
onInstructorStreamingChange={onInstructorStreamingChange}
instructorStreaming={instructorStreaming}
/>
) : (
<PBLV2SubmissionPanel
project={project}
onProjectChange={onProjectChange}
onEvaluationStatusChange={setSubmissionEvaluationStatus}
onInstructorStreamingChange={onInstructorStreamingChange}
instructorStreaming={instructorStreaming}
/>
)}
</Panel>
</div>
);
}
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 (
<header
className="relative z-40 col-span-full grid min-w-0 items-center overflow-hidden border-b border-cyan-100/[0.12] bg-[#111d35]/88 px-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.09),0_14px_42px_rgba(5,12,28,0.24)] backdrop-blur-xl"
style={{
gridTemplateColumns: `${panelWidths.sidebar}fr 6px ${panelWidths.chat}fr 6px ${panelWidths.submission}fr`,
}}
>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_0%,rgba(157,140,255,0.20),transparent_30%),radial-gradient(circle_at_78%_0%,rgba(34,211,238,0.13),transparent_26%),linear-gradient(90deg,rgba(255,255,255,0.05),transparent_34%,rgba(255,255,255,0.035))]" />
<div className="relative flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-violet-200/25 bg-violet-100/[0.08] shadow-[0_0_24px_rgba(157,140,255,0.18)]">
<Image
src={opsApiPath('/openmaic-mark.png')}
alt="OpenMAIC"
width={28}
height={28}
className="h-6 w-6"
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="line-clamp-1 text-base font-semibold tracking-tight text-white">
{project.title}
</h2>
</div>
{/* 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). */}
<nav
className="mt-0.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide"
aria-label={t('pbl.v2.workspace.returnToHero')}
>
<button
type="button"
onClick={onReturnToHero}
title={t('pbl.v2.workspace.returnToHero')}
className="rounded bg-gradient-to-r from-violet-200 via-cyan-200 to-sky-200 bg-clip-text text-transparent transition-opacity hover:opacity-80 focus-visible:underline focus-visible:outline-none"
>
{t('pbl.v2.workspace.breadcrumbOverview')}
</button>
<span aria-hidden className="text-indigo-100/40">
</span>
<span className="text-indigo-100/55">{t('pbl.v2.workspace.breadcrumbCurrent')}</span>
</nav>
</div>
</div>
<div className="relative col-start-3 hidden min-w-0 items-center gap-2 lg:flex">
<div className="flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-indigo-100/[0.14] bg-white/[0.045] px-2.5 text-[11px] font-medium text-indigo-100/82">
<Workflow className="h-3.5 w-3.5 text-violet-200/90" />
{t('pbl.v2.workspace.progressLabel')}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{project.milestones.map((milestone, index) => {
const active = index === activeMilestoneIndex;
const completed = milestone.status === 'completed';
return (
<div
key={milestone.id}
className="group relative flex min-w-0 flex-1 items-center"
title={milestone.title}
>
<div
className={cn(
'h-1.5 min-w-4 flex-1 rounded-full transition-colors',
completed && 'bg-cyan-300/70 shadow-[0_0_12px_rgba(103,232,249,0.30)]',
active && 'bg-violet-300 shadow-[0_0_16px_rgba(167,139,250,0.42)]',
!active && !completed && 'bg-slate-500/35',
)}
/>
{active && (
<div
className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/50 bg-violet-300 shadow-[0_0_18px_rgba(167,139,250,0.58)] transition-[left] duration-500 ease-out"
style={{ left: `${milestoneProgressFraction(milestone) * 100}%` }}
/>
)}
</div>
);
})}
</div>
</div>
{onExpand && (
<button
type="button"
onClick={onExpand}
className="absolute right-3 top-1/2 z-50 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md border border-cyan-100/[0.18] bg-white/[0.06] text-indigo-100/85 shadow-sm backdrop-blur transition-colors hover:bg-white/[0.12]"
aria-label={t('pbl.v2.workspace.enterFullscreen')}
title={t('pbl.v2.workspace.enterFullscreen')}
>
<Maximize2 className="h-4 w-4" />
</button>
)}
</header>
);
}
/** 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 (
<div
role="separator"
aria-orientation="vertical"
aria-label={
side === 'left'
? t('pbl.v2.workspace.resizeSidebar')
: t('pbl.v2.workspace.resizeSubmission')
}
className={cn(
'group relative z-30 row-start-2 h-full cursor-col-resize select-none bg-transparent',
active && 'bg-primary/[0.04]',
)}
onMouseDown={(event) => onMouseDown(event, side)}
>
<div
className={cn(
'absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-cyan-100/[0.18] transition-colors',
'group-hover:bg-primary/55',
active && 'bg-primary/75',
)}
/>
<div
className={cn(
'absolute left-1/2 top-1/2 h-10 w-px -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/60 opacity-0 shadow-[0_0_14px_rgba(157,140,255,0.28)] transition-all',
'group-hover:h-14 group-hover:opacity-100',
active && 'h-16 opacity-100',
)}
/>
</div>
);
}
function Panel({ slot, children }: { readonly slot: PanelSlot; readonly children: ReactNode }) {
return (
<div
className={cn(
'relative row-start-2 h-full min-w-0 overflow-hidden backdrop-blur-[2px]',
slot === 'sidebar' &&
'bg-[linear-gradient(180deg,rgba(28,39,71,0.96)_0%,rgba(22,34,62,0.94)_100%)] shadow-[inset_-18px_0_38px_rgba(5,12,28,0.10)]',
slot === 'chat' &&
'bg-[radial-gradient(circle_at_50%_0%,rgba(99,102,241,0.10),transparent_34%),linear-gradient(180deg,rgba(15,27,51,0.78)_0%,rgba(11,23,43,0.82)_100%)]',
slot === 'submission' &&
'bg-[linear-gradient(180deg,rgba(18,43,65,0.94)_0%,rgba(18,32,58,0.94)_100%)] shadow-[inset_18px_0_38px_rgba(5,12,28,0.10)]',
)}
>
<div className="relative z-10 h-full">{children}</div>
</div>
);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}