checkpoint: preserve reflection and response-contract work
This commit is contained in:
+28
-311
@@ -4,9 +4,7 @@ import React, { useEffect } from "react";
|
||||
import { useState, useRef, useMemo } from "react";
|
||||
import DiagnosticsView from "@/components/diagnostics-view";
|
||||
import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace";
|
||||
import ExperimentalBranchSwitcher, { PulseStyle } from "@/components/experimental/branch-switcher";
|
||||
import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
|
||||
import { useBranchScopedFixture } from "@/lib/fixtures/rto26b-branch-scoped.mjs";
|
||||
|
||||
/* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */
|
||||
const MOCK_ENABLED = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCKS === "true";
|
||||
@@ -231,19 +229,13 @@ export function hasValidInvestigationContext(result, status, scenario) {
|
||||
* Derives the primary surface that must render for the given state tuple.
|
||||
* Enforces exactly-one-primary-surface invariant: no zero, no two.
|
||||
*/
|
||||
export function derivePrimarySurface(result, status, showExperimentView, scenario, activeBranchId) {
|
||||
export function derivePrimarySurface(result, status, _showExperimentView, scenario, activeBranchId) {
|
||||
if (status === "loading") return "LOADING";
|
||||
if (status === "error") return "ERROR_SURFACE";
|
||||
|
||||
const valid = hasValidInvestigationContext(result, status, scenario);
|
||||
|
||||
// RTO.28B: show question-selection surface when investigation exists but no question/branch is active
|
||||
if (showExperimentView && valid && !activeBranchId) return "BRANCH_SELECTION";
|
||||
|
||||
if (showExperimentView && valid) return "EXPERIMENT_NOTEBOOK";
|
||||
if (!showExperimentView && valid) return "NORMAL_WORKSPACE";
|
||||
if (!showExperimentView) return "SCENARIO_ENTRY";
|
||||
// showExperimentView === true but no valid context → fall back to entry
|
||||
if (valid) return "NORMAL_WORKSPACE";
|
||||
return "SCENARIO_ENTRY";
|
||||
}
|
||||
|
||||
@@ -261,84 +253,6 @@ export default function ScenarioForm() {
|
||||
const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
/* ── RTO.25A — passive late-result branch switcher (experimental) ── */
|
||||
|
||||
// RTO.28A: no active branch until the user explicitly chooses one.
|
||||
const [activeBranchId, setActiveBranchId] = useState(null);
|
||||
// Pre-seed Competitor development with a late result for RTO.27A testing
|
||||
const [branchNewResults, setBranchNewResults] = useState({ "branch-a": true });
|
||||
|
||||
/* ── RTO.27B — provisional done-for-now state (experimental) ── */
|
||||
|
||||
const [doneForNowBranchIds, setDoneForNowBranchIds] = useState([]);
|
||||
|
||||
/* ── RTO.26B — experimental branch-scoped reasoning fixture ───── */
|
||||
|
||||
const branchScoped = useBranchScopedFixture();
|
||||
// Toggle to control when the experiment view is visible vs production
|
||||
// Always start false for SSR-safe deterministic first render.
|
||||
// sessionStorage reads are deferred to useEffect (after mount).
|
||||
const [showExperimentView, setShowExperimentView] = useState(false);
|
||||
|
||||
// Use fixture branches as the authoritative source when available
|
||||
const BRANCHES = useMemo(() => branchScoped.getBranches(), [branchScoped]);
|
||||
|
||||
// Track the originating question for provenance
|
||||
const [originQuestion, setOriginQuestion] = useState(null);
|
||||
|
||||
// Extract inferred questions from the fixture (flat list for post-Analyse surface)
|
||||
const inferredQuestions = useMemo(() => {
|
||||
if (!branchScoped || typeof branchScoped.getAllInferredQuestions !== 'function') return [];
|
||||
return branchScoped.getAllInferredQuestions();
|
||||
}, [branchScoped]);
|
||||
|
||||
// Simulate a late semantic result arriving on Branch A ~2s after a graph loads
|
||||
useEffect(() => {
|
||||
if (status !== "success" && status !== "error") return;
|
||||
if (branchNewResults["branch-a"]) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setBranchNewResults((prev) => ({ ...prev, "branch-a": true }));
|
||||
}, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status, branchNewResults]);
|
||||
|
||||
// Compute branch-local data for the active branch (explicit provenance)
|
||||
const activeBranch = BRANCHES.find(b => b.id === activeBranchId);
|
||||
const experimentalBranchQuestions = useMemo(() => {
|
||||
if (!activeBranch) return [];
|
||||
return branchScoped.getBranchQuestions(activeBranch.id);
|
||||
}, [activeBranch, branchScoped]);
|
||||
|
||||
const experimentalBranchContributions = useMemo(() => {
|
||||
if (!activeBranch) return [];
|
||||
return branchScoped.getBranchContributions(activeBranch.id);
|
||||
}, [activeBranch, branchScoped]);
|
||||
|
||||
// RTO.27A — late results for active branch
|
||||
const experimentalBranchLateResults = useMemo(() => {
|
||||
if (!activeBranch) return [];
|
||||
return (branchScoped.getBranchLateResults?.(activeBranch.id) || []).map(lr => ({ text: lr.text }));
|
||||
}, [activeBranch, branchScoped]);
|
||||
|
||||
// Determine which non-active branches have new results for passive indicator
|
||||
// (RTO.27B: also include done-for-now branches so pause state is visible)
|
||||
const inactiveBranchNewResults = useMemo(() => {
|
||||
const result = {};
|
||||
BRANCHES.forEach(b => {
|
||||
if (b.id !== activeBranchId) {
|
||||
if (b.id in branchNewResults) {
|
||||
result[b.id] = true;
|
||||
}
|
||||
// Show pause indicator on any done-for-now branch
|
||||
if (doneForNowBranchIds.includes(b.id)) {
|
||||
result[b.id] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [activeBranchId, BRANCHES, branchNewResults, doneForNowBranchIds]);
|
||||
|
||||
/* ── Valid investigation predicate ─────────────────────── */
|
||||
|
||||
// Delegated to the exported utility below.
|
||||
@@ -361,33 +275,9 @@ export default function ScenarioForm() {
|
||||
// investigation data to render.
|
||||
if (hasGraph) {
|
||||
setStatus("success");
|
||||
setShowExperimentView(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Restore experiment view preference from storage (after hydration) ─ */
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Only promote to experiment mode when there is real investigation
|
||||
// data to render. The ce-show-experiment flag is a presentation
|
||||
// preference, not proof that an investigation exists.
|
||||
if (!validCtx) return;
|
||||
|
||||
const savedExp = sessionStorage?.getItem("ce-show-experiment");
|
||||
if (savedExp === "true") {
|
||||
setShowExperimentView(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also enable experiment mode if session data provides a graph
|
||||
// (covers the pre-restoration case where scenario was typed but not yet submitted).
|
||||
const session = getSession();
|
||||
if (session?.situationGraph) {
|
||||
setShowExperimentView(true);
|
||||
}
|
||||
}, [validCtx]);
|
||||
|
||||
/* Restore facilitator dismiss preference (Experiment 05) ─── */
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -542,135 +432,8 @@ export default function ScenarioForm() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── RTO.26B — standalone branch-scoped experimental view (shown when experiment is active) ───── */}
|
||||
{showExperimentView && validCtx && status !== "loading" && (
|
||||
<>
|
||||
<PulseStyle />
|
||||
{!activeBranchId ? (
|
||||
/* RTO.28B: inferred questions surface — user chooses what to investigate */
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Situation remains visible */}
|
||||
{currentUnderstanding && (
|
||||
<div className="rounded-lg border border-gray-200/60 bg-gray-50/40 px-5 py-4">
|
||||
<h2 className="mb-2 text-[11px] font-semibold tracking-widest uppercase text-gray-500">
|
||||
Situation
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed text-gray-700">{currentUnderstanding}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inferred questions — user chooses what to investigate */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xs font-medium text-gray-700">Questions to explore</h2>
|
||||
<p className="text-sm text-gray-500">Click a question to start investigating. A branch will be created for your choice.</p>
|
||||
|
||||
{inferredQuestions.map((q) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => {
|
||||
const targetBranch = BRANCHES.find(b => b.id === q.branchId);
|
||||
if (targetBranch) {
|
||||
setActiveBranchId(targetBranch.id);
|
||||
setOriginQuestion({ id: q.id, text: q.text });
|
||||
}
|
||||
}}
|
||||
className="w-full text-left rounded-lg border border-gray-200 bg-white px-5 py-4 transition hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="block text-sm leading-relaxed text-gray-900">{q.text}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{inferredQuestions.length === 0 && (
|
||||
<p className="text-sm text-gray-400">No questions available yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Has active branch — show notebook */
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{/* Workspace (3/4) — branch-scoped fixture only, no API needed */}
|
||||
<div className="lg:col-span-3">
|
||||
{(() => {
|
||||
const activeBranch = BRANCHES.find(b => b.id === activeBranchId);
|
||||
const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null;
|
||||
return (
|
||||
<ReasoningWorkspace
|
||||
scenario={scenario}
|
||||
status={status}
|
||||
updateStatus="idle"
|
||||
currentUnderstanding={currentUnderstanding}
|
||||
result={{ situationGraph: null, selectedQuestion: null, newlySurfacedNodeIds: [], diagnostics: null }}
|
||||
answer=""
|
||||
setAnswer={() => {}}
|
||||
onAnswerSubmit={async (e) => e.preventDefault()}
|
||||
lastSubmittedAnswer=""
|
||||
branchContext={{ ...branchContext, originQuestion: originQuestion }}
|
||||
experimentalBranches={BRANCHES.length > 0 ? BRANCHES : undefined}
|
||||
branchLocalQuestions={experimentalBranchQuestions.length > 0 ? experimentalBranchQuestions : undefined}
|
||||
branchLocalContributions={experimentalBranchContributions.length > 0 ? experimentalBranchContributions : undefined}
|
||||
branchLocalLateResults={experimentalBranchLateResults.length > 0 ? experimentalBranchLateResults : undefined}
|
||||
inactiveBranchNewResults={Object.keys(inactiveBranchNewResults).length > 0 ? inactiveBranchNewResults : undefined}
|
||||
activeBranchIdForNotebook={activeBranchId}
|
||||
doneForNowBranchIds={doneForNowBranchIds}
|
||||
onDoneForNow={() => {
|
||||
if (activeBranchId && !doneForNowBranchIds.includes(activeBranchId)) {
|
||||
setDoneForNowBranchIds(prev => [...prev, activeBranchId]);
|
||||
}
|
||||
}}
|
||||
onReopenBranch={(id) => {
|
||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||
setActiveBranchId(id);
|
||||
}}
|
||||
onRestart={() => { setStatus("idle"); setResult(null); setScenario(""); }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Branch switcher (1/4 sidebar) */}
|
||||
<div className="lg:col-span-1">
|
||||
<ExperimentalBranchSwitcher
|
||||
branches={BRANCHES}
|
||||
activeBranchId={activeBranchId}
|
||||
branchNewResults={branchNewResults}
|
||||
branchPauseState={doneForNowBranchIds}
|
||||
onBranchSelect={(id) => {
|
||||
if (id === activeBranchId) return;
|
||||
// Reopen: if the selected branch is paused, clear its pause state
|
||||
if (doneForNowBranchIds.includes(id)) {
|
||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||
}
|
||||
setActiveBranchId(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Experiment toggle (visible when experiment is NOT shown) ─ */}
|
||||
{!showExperimentView && status !== "loading" && !result?.updatedSituationGraph && (
|
||||
<div className="rounded-lg border border-gray-200/60 bg-gray-50/30 px-4 py-3 text-center">
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Production view active.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowExperimentView(true);
|
||||
try { window.sessionStorage?.setItem("ce-show-experiment", "true"); } catch {}
|
||||
}}
|
||||
className="rounded-lg border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50 transition"
|
||||
>
|
||||
Try branch-scoped experiment (RTO.26B)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Idle form for scenario input (shown only when experiment is off) ─ */}
|
||||
{!showExperimentView && !result?.situationGraph && status === "idle" && (
|
||||
{/* ── Idle form for scenario input ─ */}
|
||||
{!result?.situationGraph && status === "idle" && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
||||
{/* Two-column landing workspace */}
|
||||
@@ -789,54 +552,30 @@ export default function ScenarioForm() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Main result workspace (only when experiment view is off) ─── */}
|
||||
{(!showExperimentView && (status === "success" || status === "error")) && (
|
||||
{/* ── Main result workspace ─── */}
|
||||
{(status === "success" || status === "error") && (
|
||||
<>
|
||||
<PulseStyle />
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
|
||||
{/* Workspace (3/4) */}
|
||||
<div className="lg:col-span-3">
|
||||
{/* Build branch context for the active branch */}
|
||||
{(() => {
|
||||
const activeBranch = BRANCHES.find(b => b.id === activeBranchId);
|
||||
const branchContext = activeBranch ? { label: activeBranch.label, origin: activeBranch.origin } : null;
|
||||
return (
|
||||
<ReasoningWorkspace
|
||||
scenario={scenario}
|
||||
status={status}
|
||||
updateStatus={updateStatus}
|
||||
currentUnderstanding={currentUnderstanding}
|
||||
result={{
|
||||
...(result || {}),
|
||||
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
|
||||
selectedQuestion: updateResult?.selectedQuestion ?? result?.selectedQuestion,
|
||||
newlySurfacedNodeIds: result?.newlySurfacedNodeIds || [],
|
||||
diagnostics: result?.diagnostics || null,
|
||||
updateError,
|
||||
}}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
branchContext={branchContext}
|
||||
// ── RTO.26B — branch-local reasoning from explicit provenance ──
|
||||
experimentalBranches={BRANCHES.length > 0 ? BRANCHES : undefined}
|
||||
branchLocalQuestions={experimentalBranchQuestions.length > 0 ? experimentalBranchQuestions : undefined}
|
||||
branchLocalContributions={experimentalBranchContributions.length > 0 ? experimentalBranchContributions : undefined}
|
||||
branchLocalLateResults={experimentalBranchLateResults.length > 0 ? experimentalBranchLateResults : undefined}
|
||||
inactiveBranchNewResults={Object.keys(inactiveBranchNewResults).length > 0 ? inactiveBranchNewResults : undefined}
|
||||
activeBranchIdForNotebook={activeBranchId}
|
||||
doneForNowBranchIds={doneForNowBranchIds}
|
||||
onDoneForNow={() => {
|
||||
if (activeBranchId && !doneForNowBranchIds.includes(activeBranchId)) {
|
||||
setDoneForNowBranchIds(prev => [...prev, activeBranchId]);
|
||||
}
|
||||
}}
|
||||
onReopenBranch={(id) => {
|
||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||
setActiveBranchId(id);
|
||||
}}
|
||||
onRestart={() => {
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Workspace — uses result from Analyse or Update only */}
|
||||
<div className="lg:col-span-2">
|
||||
<ReasoningWorkspace
|
||||
scenario={scenario}
|
||||
status={status}
|
||||
updateStatus={updateStatus}
|
||||
currentUnderstanding={currentUnderstanding}
|
||||
result={{
|
||||
...(result || {}),
|
||||
situationGraph: updateResult?.updatedSituationGraph ?? result?.situationGraph,
|
||||
selectedQuestion: updateResult?.selectedQuestion ?? result?.selectedQuestion,
|
||||
newlySurfacedNodeIds: result?.newlySurfacedNodeIds || [],
|
||||
diagnostics: result?.diagnostics || null,
|
||||
updateError,
|
||||
}}
|
||||
answer={answer}
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
onRestart={() => {
|
||||
clearSession();
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
@@ -848,29 +587,7 @@ export default function ScenarioForm() {
|
||||
setUpdateError(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Branch switcher (1/4 sidebar) — suppressed during initial reflection */}
|
||||
{!((status === "success" && result?.situationGraph && !result?.selectedQuestion)) && (
|
||||
<div className="lg:col-span-1">
|
||||
<ExperimentalBranchSwitcher
|
||||
branches={BRANCHES}
|
||||
activeBranchId={activeBranchId}
|
||||
branchNewResults={branchNewResults}
|
||||
branchPauseState={doneForNowBranchIds}
|
||||
onBranchSelect={(id) => {
|
||||
if (id === activeBranchId) return;
|
||||
// Reopen: if the selected branch is paused, clear its pause state
|
||||
if (doneForNowBranchIds.includes(id)) {
|
||||
setDoneForNowBranchIds(prev => prev.filter(x => x !== id));
|
||||
}
|
||||
setActiveBranchId(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user