feat: Phase 2-5 UX enhancements — recovery cards, session persistence, summary panel, contract backlog

Phase 2: Recovery state components (ProviderUnavailableCard,
MalformedResponseCard, UnexpectedStateCard, ContinueLaterBanner) with
automatic error detection for provider/network/malformed/unexpected states.

Phase 3: Session persistence via sessionStorage — save after each
successful turn, restore on mount, clear on restart/reset. Continuelater banner shown when session is restored.

Phase 4: InvestigationSummaryPanel component displaying current status,
understanding summary, questions answered/remaining, investigation timestamps.

Phase 5: docs/reasoning-contract-backlog.md documenting all mocked
fields (60+ rows across 7 categories) with feature/UI need/mock/desired
output/stage/notes columns.

Also: wired onRestart through ReasoningWorkspace → ScenarioForm, fixed
getErrorType scope issues, removed broken window.__restartInvestigation.
This commit is contained in:
2026-08-05 06:48:59 +01:00
parent 28289bb4b7
commit c4f5744c30
6 changed files with 1007 additions and 113 deletions
+185 -5
View File
@@ -4,6 +4,7 @@ import React, { useState, useRef, useEffect, useMemo } from "react";
import DiagnosticsView from "@/components/diagnostics-view";
import GraphUpdateView from "@/components/graph-update-view";
import SituationGraphView from "@/components/situation-graph-view";
import InvestigationSummaryPanel from "@/components/investigation-summary-panel";
// ── Technical summary detector (main view filters these) ───
const TECHNICAL_PATTERNS = [
@@ -25,6 +26,125 @@ function isTechnicalSummary(summary) {
return false;
}
// ── Recovery state components (Phase 2) ───────────────────────
function ProviderUnavailableCard({ onRestart }) {
return (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-amber-700">Provider unavailable</h2>
<p className="text-sm text-amber-800 mb-4">
The reasoning service could not be reached. This is usually temporary check that the local model is running and try again.
</p>
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg border border-amber-300 bg-white px-4 py-2 text-sm font-medium text-amber-800 hover:bg-amber-100"
>
Restart investigation
</button>
)}
</div>
);
}
function MalformedResponseCard({ onRestart }) {
return (
<div className="rounded-lg border border-orange-300 bg-orange-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-orange-700">Unexpected response</h2>
<p className="text-sm text-orange-800 mb-4">
The reasoning service returned a response we could not interpret. This may indicate a temporary issue with the model output format.
</p>
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg border border-orange-300 bg-white px-4 py-2 text-sm font-medium text-orange-800 hover:bg-orange-100"
>
Try again
</button>
)}
</div>
);
}
function UnexpectedStateCard({ stateName, onRetry, onRestart }) {
return (
<div className="rounded-lg border border-red-300 bg-red-50 px-5 py-6 text-center">
<h2 className="mb-1 text-sm font-bold uppercase tracking-wide text-red-700">Unexpected state</h2>
<p className="text-sm text-red-800 mb-4">
{stateName ? `The system is in an unexpected state (${stateName}).` : "An unexpected internal error occurred."}
Please restart the investigation to continue.
</p>
<div className="flex items-center justify-center gap-3">
{onRetry && (
<button
onClick={onRetry}
className="rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-800 hover:bg-red-100"
>
Retry update
</button>
)}
{onRestart && (
<button
onClick={onRestart}
className="rounded-lg bg-red-700 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Restart investigation
</button>
)}
</div>
</div>
);
}
function ContinueLaterBanner({ onRestart }) {
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 px-5 py-4 text-center">
<p className="text-sm text-blue-800">
Your previous investigation state is still saved. You can continue where you left off or start fresh.
</p>
{onRestart && (
<button
onClick={onRestart}
className="mt-2 text-sm font-medium text-blue-700 underline hover:text-blue-900"
>
Restart investigation
</button>
)}
</div>
);
}
// ── Session persistence hook (Phase 3) ────────────────────────
function useSessionPersistence() {
const [sessionReady, setSessionReady] = useState(false);
const sessionKey = "confidence-engine-session";
function saveSession(state) {
if (typeof sessionStorage === "undefined") return;
try {
sessionStorage.setItem(sessionKey, JSON.stringify(state));
} catch (_) { /* quota or disabled — ignore silently */ }
}
function loadSession() {
if (typeof sessionStorage === "undefined") return null;
try {
const raw = sessionStorage.getItem(sessionKey);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
function clearSession() {
if (typeof sessionStorage === "undefined") return;
try { sessionStorage.removeItem(sessionKey); } catch (_) {}
}
return { saveSession, loadSession, clearSession, sessionReady: true };
}
// ── Current understanding card ────────────────────────────────
// Evidence-limit text that must not appear inside Current understanding
@@ -387,6 +507,18 @@ function LoadingOverlay({ isLoading, elapsed, currentMessage, variant }) {
}
// ── Main workspace component ──────────────────────────────────
function getErrorType(errorStr, stage, hasGraph) {
if (!errorStr && !stage) return null;
const lower = (errorStr || "").toLowerCase();
if (/provider|unavailable|network|timeout/.test(lower)) return "provider-unavailable";
if (/malformed|invalid.*format|parse|structured/.test(lower)) return "malformed-response";
if (stage === "provider") return "provider-error";
if (stage === "unexpected") return "unexpected-state";
if (/validation/.test(lower) && !hasGraph) return "no-graph";
return null;
}
export default function ReasoningWorkspace({
scenario,
status,
@@ -397,10 +529,25 @@ export default function ReasoningWorkspace({
setAnswer,
onAnswerSubmit,
lastSubmittedAnswer,
onRestart,
}) {
const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0);
const pendingTurnRef = useRef(null);
const { saveSession, loadSession } = useSessionPersistence();
// Persist workspace state on every successful update (Phase 3)
useEffect(() => {
if (updateStatus === "success" && result?.situationGraph) {
saveSession({
scenario,
situationGraph: result.situationGraph,
selectedQuestion: result.selectedQuestion,
summary: result.summary || propUnderstanding,
updatedAt: new Date().toISOString(),
});
}
}, [updateStatus, result]);
// Capture the current selected question at submit time (not from a stale ref)
const capturePendingTurn = (selectedQuestion, answerText) => {
@@ -435,6 +582,21 @@ export default function ReasoningWorkspace({
await onAnswerSubmit(e);
};
const graph = result?.situationGraph ?? null;
const diagnostics = result?.diagnostics ?? null;
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
const genuineCompletion = hasGenuineCompletion(graph);
const errorType = getErrorType(
result?.error || (result?.updateError ? result.updateError.error : null),
result?.stage,
Boolean(graph)
);
const isProviderUnavailable =
errorType === "provider-unavailable" || errorType === "provider-error";
const isMalformedResponse = errorType === "malformed-response";
const { elapsed: startElapsed, currentMessage: startMsg } = useLoadingStatus(
INITIAL_MESSAGES,
status === "loading"
@@ -455,10 +617,6 @@ export default function ReasoningWorkspace({
hasSelectedQuestion;
const selectedQ = result?.selectedQuestion ?? null;
const graph = result?.situationGraph ?? null;
const diagnostics = result?.diagnostics ?? null;
const newlySurfacedNodeIds = result?.newlySurfacedNodeIds || [];
const genuineCompletion = hasGenuineCompletion(graph);
// Determine whether the Current Understanding card should render:
// — when there is a durable plain-language understanding, or
@@ -485,6 +643,25 @@ export default function ReasoningWorkspace({
/>
)}
{/* ── Provider unavailable recovery ──────────────── */}
{isProviderUnavailable && (
<ProviderUnavailableCard onRestart={onRestart} />
)}
{/* ── Malformed response recovery ────────────────── */}
{isMalformedResponse && (
<MalformedResponseCard onRestart={onRestart} />
)}
{/* ── Unexpected state recovery ──────────────────── */}
{(errorType === "unexpected-state") && result && (
<UnexpectedStateCard
stateName={result.stage || null}
onRetry={updateStatus === "error" ? onRestart : null}
onRestart={onRestart}
/>
)}
{/* ── No graph produced after initial analysis ───────── */}
{(status === "success" || status === "error") && !graph ? (
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800">
@@ -494,6 +671,9 @@ export default function ReasoningWorkspace({
</div>
) : (
<>
{/* ── Investigation summary card ─────────────── */}
<InvestigationSummaryPanel graph={graph} selectedQuestion={selectedQ} result={result} />
{/* ── Active investigation: question + form (top priority) ─ */}
{canAnswer && (
<>
@@ -583,4 +763,4 @@ export default function ReasoningWorkspace({
);
}
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary };
export { useLoadingStatus, INITIAL_MESSAGES, UPDATE_MESSAGES, LoadingOverlay, resolveCurrentSummary, isTechnicalSummary, ContinueLaterBanner };