test(ui): make initial branch selection user owned

This commit is contained in:
2026-08-20 14:11:37 +01:00
parent 4761d07a76
commit 65ced2e406
2 changed files with 371 additions and 61 deletions
+163 -61
View File
@@ -209,6 +209,44 @@ function clearSession() {
try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {}
}
/**
* Derives whether the current component state represents a valid investigation
* context sufficient to render a workspace surface.
*
* Valid only when:
* - result carries a situationGraph (renderable graph), OR
* - status is "success" AND there is a non-empty scenario
* (from session restoration with real data).
*
* This predicate is the single source of truth for all render-gate decisions.
* showExperimentView, fixture availability, or sessionStorage keys alone are
* NOT sufficient to constitute valid context.
*/
export function hasValidInvestigationContext(result, status, scenario) {
return Boolean(result?.situationGraph) ||
(status === "success" && Boolean(scenario?.trim()));
}
/**
* 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) {
if (status === "loading") return "LOADING";
if (status === "error") return "ERROR_SURFACE";
const valid = hasValidInvestigationContext(result, status, scenario);
// RTO.28A: show branch-selection surface when investigation exists but no 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
return "SCENARIO_ENTRY";
}
export default function ScenarioForm() {
const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success
@@ -225,7 +263,8 @@ export default function ScenarioForm() {
/* ── RTO.25A — passive late-result branch switcher (experimental) ── */
const [activeBranchId, setActiveBranchId] = useState("branch-a");
// 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 });
@@ -291,30 +330,54 @@ export default function ScenarioForm() {
return result;
}, [activeBranchId, BRANCHES, branchNewResults, doneForNowBranchIds]);
/* ── Valid investigation predicate ─────────────────────── */
// Delegated to the exported utility below.
const validCtx = hasValidInvestigationContext(result, status, scenario);
/* Restore persisted session on mount (Phase 3) ─────────── */
useEffect(() => {
if (typeof window === "undefined") return;
const saved = getSession();
if (!saved) return;
const hasGraph = Boolean(saved.situationGraph);
setScenario(saved.scenario || "");
setResult(saved.situationGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
setCurrentUnderstanding(saved.summary || null);
setStatus("success");
// Partial sessions (present but no graph) must NOT suppress the
// scenario-entry form. Only promote to success when there is actual
// 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(() => {
@@ -471,67 +534,106 @@ export default function ScenarioForm() {
return (
<div className="space-y-6">
{/* ── RTO.26B — standalone branch-scoped experimental view (shown when experiment is active) ───── */}
{showExperimentView && (result?.situationGraph || status === "success") && status !== "loading" && (
{showExperimentView && validCtx && status !== "loading" && (
<>
<PulseStyle />
<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}
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>
{!activeBranchId ? (
/* RTO.28A: no active branch — show branch-selection surface */
<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>
{/* 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);
}}
/>
{/* Branch selection */}
<div className="space-y-4">
<h2 className="text-xs font-medium text-gray-700">Choose a branch to continue</h2>
<p className="text-sm text-gray-500">These are the current lines of inquiry. Pick whichever you want to work on.</p>
{BRANCHES.map((branch) => (
<button
key={branch.id}
onClick={() => setActiveBranchId(branch.id)}
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 font-medium text-sm text-gray-900">{branch.label}</span>
{branch.origin && (
<span className="block mt-1 text-xs leading-relaxed text-gray-500/80">
{branch.origin}
</span>
)}
</button>
))}
</div>
</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}
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>
)}
</>
)}