Files
confidence-engine/components/scenario-form.jsx
T
robbond 99b3d26817 feat(confidence-engine): v0.59a — correct Investigation revision provenance
Semantic revision tracking ensures every meaningful persisted
Investigation change advances investigationRevision exactly once,
while Report generation records (but does not advance) the current
revision as generatedFromRevision for provenance integrity.

Corrections:
- updateFindingDisposition: add setInvestigationRevision(+1) for
  semantic transitions (eligible→not_relevant, restore)
- updateFindingProposition: add no-op guard + setInvestigationRevision(+1)
- onRestart/ContinueLaterBanner/reset button: add setInvestigationRevision(0)
- onSituationGraphChange (Re-open seam): already had revision +1 in dirty impl

Established behaviour preserved:
- Re-open via reopenResolvedUnknown → onSituationGraphChange → revision +1
- Empty Done via handleDoneForNowPromotion → revision +1
- Report generation records generatedFromRevision, advances by 0
- Autosave passes revision but does not increment it
- clearInvestigation() ownership intact

Tests: targeted Vitest suite (17 tests) covering all provenance boundaries.

Durable rule documented in current-handoff.md §v0.59a.
2026-09-03 13:39:40 +01:00

1001 lines
38 KiB
React

"use client";
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 { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client";
import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers";
import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage";
/* 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";
/* ── inject runtime globals for the mock client to read ──── */
function useMockGlobals() {
useEffect(() => {
if (MOCK_ENABLED) {
var w = window;
w.__MOCK_ENABLED = true;
w.__MOCK_DELAY = process.env.NEXT_PUBLIC_CONFIDENCE_MOCK_DELAY || "normal";
w.__MOCK_SCENARIO = process.env.NEXT_PUBLIC_CONFIDENCE_ENGINE_MOCK_SCENARIO || "";
}
}, []);
}
const MAX_LENGTH = 10000;
export async function submitScenarioForStartCase(fetchImpl, scenario) {
return fetchImpl("/api/cases/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenario }),
});
}
export async function submitAnswerForUpdateCase(
fetchImpl,
{ situationGraph, previousQuestion, answer, findings },
) {
if (!answer?.trim()) {
return {
ok: false,
skipped: true,
data: {
success: false,
stage: "request_validation",
error: "Please enter an answer before updating.",
},
};
}
const body = { situationGraph, previousQuestion, answer };
if (findings && findings.length > 0) {
body.findings = findings;
}
const response = await fetchImpl("/api/cases/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return {
ok: response.ok,
skipped: false,
data: await response.json(),
};
}
export async function synthesizeFromFindings(fetchImpl, { situationGraph, findings }) {
const response = await fetchImpl("/api/cases/synthesis", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ situationGraph, findings }),
});
return {
ok: response.ok,
data: await response.json(),
};
}
function normaliseStartResult(data) {
return {
...data,
selectedQuestion:
typeof data?.selectedQuestion === "string"
? data.selectedQuestion
: data?.selectedQuestion?.question ?? null,
newlySurfacedNodeIds: data?.newlySurfacedNodeIds ?? [],
};
}
function normaliseUpdateSelectedQuestion(selectedQuestion) {
if (!selectedQuestion) return null;
if (typeof selectedQuestion === "string") return selectedQuestion;
return selectedQuestion.question ?? null;
}
export function ScenarioResultPanels({ status, result }) {
if (!result) return null;
const hasGraph = Boolean(result.situationGraph);
const hasQuestion = Boolean(result.selectedQuestion?.question);
const hasDiagnostics = Boolean(result.diagnostics);
return (
<>
{status === "error" && (
<div className="space-y-3">
{result.error && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Error: {result.error}
</div>
)}
{!hasGraph && !hasQuestion && (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-2 text-sm text-yellow-800">
Validation failed no structured graph output was produced.
</div>
)}
</div>
)}
{hasDiagnostics && <DiagnosticsView result={result} />}
</>
);
}
// ── Message pools ───────────────────────────────────────────
const INITIAL_MESSAGES = [
{ min: 0, text: "Reading your situation" },
{ min: 10, text: "Building a structured understanding" },
{ min: 25, text: "Identifying what is known and still unclear" },
{ min: 45, text: "Selecting the next useful question" },
];
const UPDATE_MESSAGES = [
{ min: 0, text: "Considering your answer" },
{ min: 10, text: "Updating the situation" },
{ min: 25, text: "Checking what changed" },
{ min: 45, text: "Choosing the next question" },
];
function useLoadingStatus(messages, isLoading) {
const [elapsed, setElapsed] = useState(0);
const startRef = useRef(null);
useEffect(() => {
if (isLoading) {
startRef.current = Date.now();
const iv = setInterval(() => {
setElapsed(Math.floor((Date.now() - startRef.current) / 1000));
}, 1000);
return () => clearInterval(iv);
} else {
setElapsed(0);
startRef.current = null;
}
}, [isLoading]);
const currentMessage = useMemo(() => {
if (!messages || messages.length === 0) return "";
let msg = messages[0].text;
for (const m of messages) {
if (elapsed >= m.min) msg = m.text;
}
return msg;
}, [messages, elapsed]);
return { elapsed, currentMessage };
}
export function UpdateErrorPanel({ updateError }) {
if (!updateError) return null;
const errors = [
...(updateError.errors || []),
...(updateError.validationErrors || []),
...(updateError.graphValidationErrors || []),
...(updateError.proposalErrors || []),
...(updateError.providerErrors || []),
];
return (
<div className="space-y-3">
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700 whitespace-pre-wrap">
Update error: {updateError.error}
</div>
{errors.length > 0 && (
<details className="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<summary className="cursor-pointer text-sm font-medium text-red-700 underline">
Update details ({errors.length})
</summary>
<ul className="mt-2 space-y-1 text-sm text-red-700">
{errors.map((item, index) => (
<li key={index}>
{typeof item === "string" ? item : item?.message || JSON.stringify(item)}
</li>
))}
</ul>
</details>
)}
</div>
);
}
export { INITIAL_MESSAGES, UPDATE_MESSAGES, useLoadingStatus };
/**
* 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);
if (valid) return "NORMAL_WORKSPACE";
return "SCENARIO_ENTRY";
}
/**
* Orchestrate the authoritative episode reconsideration flow.
* Exported for deterministic testing — domain functions and server endpoint accepted as parameters.
*/
export async function executeEpisodeDone({
resultSituationGraph,
targetNodeId,
focusedContributions,
findings,
episodeDoneServer,
synthesizeFn,
setResult: setAppState,
}) {
const serverResult = await episodeDoneServer({
situationGraph: resultSituationGraph,
targetNodeId,
contributions: focusedContributions ?? [],
findings,
});
if (!serverResult.success) {
return { success: false, stage: "episode_done", error: serverResult.error };
}
const nextGraph = serverResult.updatedSituationGraph;
setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph }));
const synthesisResult = await synthesizeFn(nextGraph, findings);
return { success: true, nextGraph, synthesisResult };
}
export default function ScenarioForm({ onNavigateToReport }) {
const [scenario, setScenario] = useState("");
const [status, setStatus] = useState("idle"); // idle | loading | error | success
const [result, setResult] = useState(null);
const [answer, setAnswer] = useState("");
const [updateStatus, setUpdateStatus] = useState("idle"); // idle | loading | error | success
const [updateError, setUpdateError] = useState(null);
const [updateResult, setUpdateResult] = useState(null);
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState("");
const [currentUnderstanding, setCurrentUnderstanding] = useState(null);
const [cuSynthesisLoading, setCuSynthesisLoading] = useState(false);
const [mockScenario, setMockScenario] = useState("");
const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false);
/* ── v0.54b — investigation overview transient state ────── */
const [overviewState, setOverviewState] = useState(null);
const [overviewLoading, setOverviewLoading] = useState(false);
/* ── v0.55 — persisted investigation report (derived artefact) ── */
const [investigationReport, setInvestigationReport] = useState(null);
/* ── v0.59a — provenance: Investigation revision tracking ── */
const [investigationRevision, setInvestigationRevision] = useState(0);
/* ── in-flight gate for episode reconsideration on Done ──── */
const doneInProgressRef = useRef(false);
/* ── RTO.31: focused contributions ownership ─────────────── */
const [focusedContributions, setFocusedContributions] = useState([]);
/* ── v2 findings from focused contributions ─────────────── */
const [findings, setFindings] = useState([]);
function appendFinding(finding) {
setFindings((prev) => {
return [...prev, finding];
});
}
function updateFindingDisposition(findingId, newDisposition) {
// Derive explicit next state — not a React-state reread.
const nextFindings = (findings ?? []).map((f) =>
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
);
setFindings(() => nextFindings);
// ── Synthesis trigger: completed canonical eligibility transition ──
const prevFinding = (findings ?? []).find((f) => f.id === findingId);
const previousDisposition = prevFinding?.userDisposition;
const notRelevantTransition =
previousDisposition !== "not_relevant" && newDisposition === "not_relevant";
const restoreTransition =
previousDisposition === "not_relevant" && newDisposition === null;
if (!notRelevantTransition && !restoreTransition) return;
/* ── v0.59a — provenance: eligible evidence set changed ── */
setInvestigationRevision((prev) => (prev ?? 0) + 1);
const currentGraph = result?.situationGraph;
if (!currentGraph) return;
void synthesizeFromFindings(fetch, {
situationGraph: currentGraph,
findings: normalizeFindings(nextFindings),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
setCurrentUnderstanding(res.data.currentUnderstanding);
}
});
}
function updateFindingProposition(findingId, newProposition) {
// Derive explicit next state — not a React-state reread.
const nextFindings = (findings ?? []).map((f) =>
f.id === findingId ? { ...f, proposition: newProposition, userDisposition: null } : f,
);
/* ── v0.59a — provenance: no-op guard ── */
const prevFinding = (findings ?? []).find((f) => f.id === findingId);
if (prevFinding?.proposition === newProposition) return; // no semantic change
setFindings(() => nextFindings);
/* ── v0.59a — provenance: corrected Finding changes evidence ── */
setInvestigationRevision((prev) => (prev ?? 0) + 1);
// ── Synthesis trigger: corrected Finding → one reconstruction ──
const currentGraph = result?.situationGraph;
if (!currentGraph) return;
void synthesizeFromFindings(fetch, {
situationGraph: currentGraph,
findings: normalizeFindings(nextFindings),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
setCurrentUnderstanding(res.data.currentUnderstanding);
}
});
}
/**
* Authoritative graph reconsideration triggered by "Done for now"
* activity boundary. Delegates to the exported executeEpisodeDone pipeline.
*/
async function handleDoneForNowPromotion(targetNodeId, onImmediateGraphUpdate) {
if (!targetNodeId) return;
// Gate: only invoke episode processing when the active target has focused contributions.
// Scenario-wide findings no longer determine whether an empty target enters episode processing.
const hasActiveTargetContent = (focusedContributions ?? []).some(
(c) => c.targetNodeId === targetNodeId || c.originatingTargetNodeId === targetNodeId,
);
if (!hasActiveTargetContent) return;
// In-flight guard: exactly-once enforcement
if (doneInProgressRef.current) return;
doneInProgressRef.current = true;
/* ── Immediate client transition — before awaiting async work ── */
const preDoneGraph = result?.situationGraph;
if (preDoneGraph && onImmediateGraphUpdate) {
const immediateResolvedIds = new Set(preDoneGraph.resolvedNodeIds || []);
immediateResolvedIds.add(targetNodeId);
const immediateGraph = {
...preDoneGraph,
resolvedNodeIds: Array.from(immediateResolvedIds),
};
onImmediateGraphUpdate(immediateGraph);
}
setCuSynthesisLoading(true);
try {
const doneResult = await executeEpisodeDone({
resultSituationGraph: preDoneGraph,
targetNodeId,
focusedContributions: focusedContributions ?? [],
findings,
episodeDoneServer: (payload) =>
fetch("/api/cases/update", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, episodeMode: true }),
}).then((res) => res.json()),
synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }),
setResult,
});
/* ── v0.59a — provenance: episode done is meaningful evidence change ── */
const nextRev = (investigationRevision ?? 0) + 1;
setInvestigationRevision(nextRev);
/* CU synthesis — install only on success */
if (doneResult?.synthesisResult?.ok && doneResult.synthesisResult.data?.currentUnderstanding) {
setCurrentUnderstanding(doneResult.synthesisResult.data.currentUnderstanding);
}
/* On synthesis failure: KEEP nextGraph, KEEP Findings, KEEP existing CU. Do NOT rollback. */
} finally {
doneInProgressRef.current = false;
setCuSynthesisLoading(false);
}
}
/**
* v0.54b/v0.55 — request investigation overview via the established POST /api/cases/overview seam.
* Produces a distinct Investigation Report: a derived artefact, not canonical reasoning state.
*/
async function handleRequestOverview() {
if (overviewLoading || !result?.situationGraph) return;
setOverviewLoading(true);
setOverviewState(null); // clear any previous overview before new request
const plausibleInput = (result.situationGraph?.reconstruction || {}).plausibleInterpretations ?? [];
const hasPlausibleInput = Array.isArray(plausibleInput) && plausibleInput.length > 0;
try {
const res = await fetch("/api/cases/overview", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
situationGraph: result.situationGraph,
findings,
plausibleInterpretations: plausibleInput,
}),
}).then((r) => r.json());
if (res?.success && res?.understanding != null) {
setOverviewState(res);
// Persist as a derived artefact of this investigation
const rev = investigationRevision ?? 0;
const report = {
understanding: res.understanding,
plausibleInterpretations: hasPlausibleInput ? res.plausibleInterpretations ?? "" : "",
hasPlausibleInterpretations: hasPlausibleInput,
generatedFromRevision: rev,
};
setInvestigationReport(report);
// Trigger autosave to persist the report
void saveInvestigation({
scenario,
situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion,
summary: currentUnderstanding,
updatedAt: new Date().toISOString(),
focusedContributions,
findings,
investigationReport: report,
investigationRevision: rev,
});
}
// On failure: do not clear existing CU, do not block further attempts
} finally {
setOverviewLoading(false);
}
}
function appendFocusedContribution(contribution) {
// Derive a single stored contribution object and use it for BOTH
// contribution storage AND Finding derivation so the same identity
// appears in focusedContributions[] and Finding.contributionId.
setFocusedContributions((prev) => {
const seq = prev.length + 1;
const storedContribution = { ...contribution, sequence: seq, id: `contrib-${String(seq).padStart(4, "0")}` };
// Derive Findings from the exact stored Contribution (not a separate approximation)
setFindings((prevFindings) => {
const newFindings = deriveFindingsFromContributions([storedContribution]).findings;
return normalizeFindings([...prevFindings, ...newFindings]);
});
return [...prev, storedContribution];
});
// ── Synthesis trigger: once per completed Finding transition ──
const newFindingsDelta = deriveFindingsFromContributions([
{
...contribution,
sequence: (focusedContributions?.length ?? 0) + 1,
id: `contrib-${String((focusedContributions?.length ?? 0) + 1).padStart(4, "0")}`,
},
]).findings;
if (newFindingsDelta.length === 0) return;
const currentGraph = result?.situationGraph;
if (!currentGraph) return;
void synthesizeFromFindings(fetch, {
situationGraph: currentGraph,
findings: normalizeFindings([...(findings ?? []), ...newFindingsDelta]),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
setCurrentUnderstanding(res.data.currentUnderstanding);
}
});
}
const textareaRef = useRef(null);
/* ── Valid investigation predicate ─────────────────────── */
// Delegated to the exported utility below.
const validCtx = hasValidInvestigationContext(result, status, scenario);
/* Restore persisted session on mount ─────────── */
useEffect(() => {
if (typeof window === "undefined") return;
const saved = loadInvestigation();
if (!saved) return;
const hasGraph = Boolean(saved.situationGraph);
setScenario(saved.scenario || "");
setResult(hasGraph ? { ...saved, situationGraph: saved.situationGraph } : null);
setCurrentUnderstanding(saved.summary || null);
setFocusedContributions(saved.focusedContributions || []);
setFindings(saved.findings || []);
/* ── v0.55 — hydrate persisted investigation report ─── */
if (saved.investigationReport) {
setInvestigationReport(saved.investigationReport);
}
/* ── v0.59a — hydrate provenance revision ─────────── */
setInvestigationRevision(saved.investigationRevision ?? 0);
// 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");
}
}, []);
/* ── Canonical autosave — persist whenever state changes (Phase 2) ── */
useEffect(() => {
if (typeof window === "undefined") return;
// Guard: no valid investigation yet → skip autosave during idle/start flows.
// Also prevents overwriting an existing saved investigation with the initial
// empty state of a fresh ScenarioForm instance (hydration race guard).
if (!result?.situationGraph) return;
void saveInvestigation({
scenario,
situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion,
summary: currentUnderstanding,
updatedAt: new Date().toISOString(),
focusedContributions,
findings,
investigationReport,
investigationRevision,
});
}, [
scenario,
result?.situationGraph,
result?.selectedQuestion,
currentUnderstanding,
focusedContributions,
findings,
investigationReport,
investigationRevision,
]);
/* Restore facilitator dismiss preference (Experiment 05) ─── */
useEffect(() => {
if (typeof window === "undefined") return;
try {
const pref = sessionStorage.getItem("ce-facilitator-dismissed");
setHideFacilitatorOnLanding(pref === "true");
} catch (_) {}
}, []);
/* Inject mock globals so the interceptor can read them at runtime */
useMockGlobals();
function handleScenarioSelect(key) {
setMockScenario(key);
if (typeof window !== "undefined") {
window.__MOCK_SCENARIO = key;
}
// Auto-fill central statement for quick start
var found = AVAILABLE_SCENARIOS.find(function(s) { return s.key === key; });
if (found && found.centralStatement) {
setScenario(found.centralStatement);
}
}
function handleScenarioFill(key) {
handleScenarioSelect(key);
setStatus("loading");
setResult(null);
setAnswer("");
setUpdateStatus("idle");
setUpdateResult(null);
setLastSubmittedAnswer("");
setCurrentUnderstanding(null);
setUpdateError(null);
// Simulate a click on the analyse button after auto-filling
setTimeout(function() {
var btn = document.querySelector('button[type="submit"]');
if (btn && !btn.disabled) btn.click();
}, 50);
}
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
);
const { elapsed: updateElapsed, currentMessage: updateMsg } = useLoadingStatus(
UPDATE_MESSAGES,
updateStatus === "loading"
);
const handleSubmit = async (e) => {
e.preventDefault();
setStatus("loading");
setResult(null);
setAnswer("");
setUpdateStatus("idle");
setUpdateResult(null);
setLastSubmittedAnswer("");
setUpdateError(null);
setCurrentUnderstanding(null);
// Force a DOM flush so loading state renders before awaiting (prevents instant mocks from swallowing it)
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 50)));
try {
const res = await submitScenarioForStartCase(MOCK_ENABLED ? mockFetch : fetch, scenario);
const data = await res.json();
if (res.ok && data.success) {
setStatus("success");
setCurrentUnderstanding(data.summary ?? null);
const normalised = normaliseStartResult(data);
setResult(normalised);
/* ── v0.59a — provenance: first meaningful change sets revision to 1 ── */
setInvestigationRevision(1);
saveInvestigation({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString(), focusedContributions, findings: [], investigationReport, investigationRevision: 1 });
} else {
setStatus("error");
setCurrentUnderstanding(data.summary ?? null);
setResult(normaliseStartResult(data));
}
} catch (err) {
setStatus("error");
setResult({ error: err.message || "Network request failed" });
}
};
const handleUpdate = async (e) => {
e.preventDefault();
// Guard empty answer before showing loading state
if (!answer?.trim()) {
setUpdateStatus("error");
setUpdateError({ error: "Please enter an answer before updating." });
return;
}
setUpdateStatus("loading");
setUpdateError(null);
setLastSubmittedAnswer(answer.trim());
// Force a DOM flush so loading state renders before awaiting (prevents instant mocks from swallowing it)
await new Promise(r => requestAnimationFrame(() => setTimeout(r, 50)));
const submission = await submitAnswerForUpdateCase(MOCK_ENABLED ? mockFetch : fetch, {
situationGraph: result?.situationGraph,
previousQuestion: result?.selectedQuestion,
answer,
findings,
});
if (submission.skipped) {
setUpdateStatus("error");
setUpdateError(submission.data);
return;
}
try {
const outcome = submission.data;
if (submission.ok && outcome.success) {
// ── Derive explicit next canonical state (no React-state reread) ──
const nextGraph = outcome.updatedSituationGraph;
let nextFindings = [...findings];
if (outcome.appendedFindings && Array.isArray(outcome.appendedFindings)) {
nextFindings = [...nextFindings, ...outcome.appendedFindings];
}
setUpdateStatus("success");
setUpdateResult({
...outcome,
previousSituationGraph: result?.situationGraph ?? null,
});
setResult((current) => ({
...current,
situationGraph: nextGraph,
selectedQuestion: normaliseUpdateSelectedQuestion(
outcome.selectedQuestion,
),
newlySurfacedNodeIds: (outcome.proposal?.addedNodes || [])
.filter((node) => node.kind === "unknown")
.map((node) => node.id),
diagnostics: outcome.diagnostics,
}));
setFindings(nextFindings);
// ── Coalesced transition: one synthesis per successful update ──
void synthesizeFromFindings(fetch, {
situationGraph: nextGraph,
findings: normalizeFindings(nextFindings),
}).then((res) => {
if (res.ok && res.data?.currentUnderstanding) {
setCurrentUnderstanding(res.data.currentUnderstanding);
}
// On synthesis failure: graph/Findings already persisted, CU preserved, no retry.
});
setAnswer("");
// Persist after successful update turn — include explicit next state
/* ── v0.59a — provenance: meaningful change advances revision ── */
const nextRev = (investigationRevision ?? 0) + 1;
setInvestigationRevision(nextRev);
saveInvestigation({ scenario, situationGraph: nextGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: currentUnderstanding, updatedAt: new Date().toISOString(), focusedContributions, findings: nextFindings, investigationReport, investigationRevision: nextRev });
} else {
setUpdateStatus("error");
setUpdateError(outcome);
}
} catch (err) {
setUpdateStatus("error");
setUpdateError({ error: err.message || "Network request failed" });
}
};
return (
<div className="space-y-6">
{/* ── Idle form for scenario input ─ */}
{!result?.situationGraph && status === "idle" && (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Two-column landing workspace */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* Left panel — Facilitator (1/3 on desktop) */}
{!hideFacilitatorOnLanding && (
<div className="md:col-span-1">
<div className="rounded-lg border border-amber-200/60 bg-gradient-to-b from-amber-50/60 to-white px-8 pt-7 pb-7 sticky top-6 shadow-sm">
<h2 className="mb-4 text-xs font-bold tracking-widest uppercase text-amber-600/60">Before we begin</h2>
<p className="text-sm leading-relaxed text-amber-900/80 mb-5">
The Confidence Engine helps build confidence by understanding situations before deciding what to do.
</p>
<p className="text-sm leading-relaxed text-amber-900/70 mb-4">
You do not need to know exactly what the problem is.
</p>
<p className="text-sm leading-relaxed text-amber-900/70 mb-7">
Simply describe what you have observed. We will work through it together, one question at a time.
</p>
<div className="flex items-center gap-2 pt-5 border-t border-amber-100/60">
<input
type="checkbox"
id="dismiss-facilitator"
onChange={(e) => {
if (e.target.checked) {
setHideFacilitatorOnLanding(true);
try { sessionStorage.setItem("ce-facilitator-dismissed", "true"); } catch (_) {}
}
}}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="dismiss-facilitator" className="text-xs text-gray-500">
{`Dismiss this introduction permanently`}
</label>
</div>
</div>
</div>
)}
{/* Right panel — Workspace (2/3 on desktop) */}
<div className={hideFacilitatorOnLanding ? "md:col-span-3" : "md:col-span-2"}>
<h2 className="mb-4 text-xs font-bold tracking-widest uppercase text-gray-400">What&#39;s the situation</h2>
<textarea
ref={textareaRef}
value={scenario}
onChange={(e) => setScenario(e.target.value)}
placeholder="What have you noticed?"
rows={4}
data-testid="scenario-textarea"
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 mb-3"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{scenario.length}/{MAX_LENGTH}</span>
<button
type="submit"
disabled={!scenario.trim()}
className="rounded-lg bg-blue-700 px-6 py-2.5 text-sm font-medium text-white transition hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
>
Analyse
</button>
</div>
<p className="mt-3 text-xs italic text-gray-400">
You do not need all the answers yet.
</p>
</div>
</div>
</form>
)}
{status === "idle" && MOCK_ENABLED && (
<details className="rounded-lg border border-gray-200/60 bg-gray-50/30">
<summary className="cursor-pointer px-4 py-2 text-sm font-medium text-gray-400 hover:text-gray-600">Developer details</summary>
<div className="space-y-3 px-4 pb-4">
<div>
<label htmlFor="mock-scenario" className="block text-xs font-medium text-gray-500 mb-1">Mock scenario</label>
<select
id="mock-scenario"
value={mockScenario}
onChange={(e) => handleScenarioSelect(e.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
<option value=""> default (env var) </option>
{AVAILABLE_SCENARIOS.map(function(s) {
return <option key={s.key} value={s.key}>{s.label}</option>;
})}
</select>
</div>
<div className="flex flex-wrap gap-2">
{AVAILABLE_SCENARIOS.map(function(s) {
return (
<button
key={s.key}
type="button"
onClick={() => handleScenarioFill(s.key)}
disabled={!scenario.trim() && scenario !== s.centralStatement}
className="rounded-md border border-gray-200/60 bg-white/80 px-3 py-1.5 text-xs text-gray-500 hover:bg-gray-50 disabled:opacity-30"
>
{s.label} ({s.turnCount} turns)
</button>
);
})}
</div>
</div>
</details>
)}
{/* ── Initial analysis loading card ─────────────── */}
{status === "loading" && (
<LoadingOverlay
isLoading={true}
elapsed={startElapsed}
currentMessage={startMsg}
variant="initial"
/>
)}
{/* ── Main result workspace ─── */}
{(status === "success" || status === "error") && (
<>
<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}
cuSynthesisLoading={cuSynthesisLoading}
currentUnderstanding={currentUnderstanding}
/* ── v0.54b investigation overview transient state ─── */
overviewState={overviewState}
setOverviewState={setOverviewState}
overviewLoading={overviewLoading}
handleRequestOverview={handleRequestOverview}
onNavigateToReport={onNavigateToReport}
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}
focusedContributions={focusedContributions}
onFocusedContribution={appendFocusedContribution}
findings={findings}
onUpdateFindingDisposition={updateFindingDisposition}
onUpdateFindingProposition={updateFindingProposition}
/* ── v0.49 done-for-now promotion seam ─────────── */
onSummaryUpdate={handleDoneForNowPromotion}
/* ── immediate graph transition (Done acknowledged before async) ── */
onImmediateGraphChange={(nextGraph) => setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }))}
/* ── v0.59a provenance tracking ─────────────────── */
investigationRevision={investigationRevision}
onSituationGraphChange={(nextGraph) => {
const nextRev = (investigationRevision ?? 0) + 1;
setInvestigationRevision(nextRev);
setResult((prev) => ({ ...(prev ?? {}), situationGraph: nextGraph }));
}}
onRestart={() => {
clearInvestigation();
setInvestigationRevision(0);
setStatus("idle");
setResult(null);
setAnswer("");
setUpdateStatus("idle");
setUpdateResult(null);
setLastSubmittedAnswer("");
setCurrentUnderstanding(null);
setUpdateError(null);
setFocusedContributions([]);
setFindings([]);
}}
/>
</div>
</div>
</>
)}
{/* ── Continue later banner when session was restored ── */}
{status === "success" && result?.updatedAt && (
<ContinueLaterBanner onRestart={() => { clearInvestigation(); setInvestigationRevision(0); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); setFocusedContributions([]); setFindings([]); }} />
)}
{/* Reset button after successful analysis */}
{status === "success" && (
<div className="text-center">
<button
onClick={() => {
clearInvestigation();
setInvestigationRevision(0);
setScenario("");
setStatus("idle");
setResult(null);
setAnswer("");
setUpdateStatus("idle");
setUpdateResult(null);
setLastSubmittedAnswer("");
setCurrentUnderstanding(null);
setUpdateError(null);
setFocusedContributions([]);
setFindings([]);
}}
className="rounded-lg border border-gray-200/60 px-4 py-2 text-sm font-medium text-gray-500 transition hover:bg-gray-50/80"
>
Start new investigation
</button>
</div>
)}
</div>
);
}