From c4f5744c30fb11669b1aeabefb0e80503ba774b3 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 5 Aug 2026 06:48:59 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202-5=20UX=20enhancements=20?= =?UTF-8?q?=E2=80=94=20recovery=20cards,=20session=20persistence,=20summar?= =?UTF-8?q?y=20panel,=20contract=20backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/investigation-summary-panel.jsx | 171 ++++++++++ components/reasoning-workspace.jsx | 190 ++++++++++- components/scenario-form.jsx | 126 ++++++- docs/reasoning-contract-backlog.md | 114 +++++++ lib/mocks/confidence-engine/mock-client.js | 146 +++----- lib/mocks/scenarios.js | 373 +++++++++++++++++++++ 6 files changed, 1007 insertions(+), 113 deletions(-) create mode 100644 components/investigation-summary-panel.jsx create mode 100644 docs/reasoning-contract-backlog.md create mode 100644 lib/mocks/scenarios.js diff --git a/components/investigation-summary-panel.jsx b/components/investigation-summary-panel.jsx new file mode 100644 index 0000000..875d794 --- /dev/null +++ b/components/investigation-summary-panel.jsx @@ -0,0 +1,171 @@ +/** + * InvestigationSummaryPanel — Phase 4 + * Displays key investigation metrics in a compact card. + * Some fields are currently mocked; TODO comments identify what the + * reasoning engine must eventually provide. + */ + +/* ── Helpers ──────────────────────────────────────────────── */ + +function formatTimestamp(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } catch { + return iso; + } +} + +function humaniseDuration(seconds) { + if (!seconds || seconds < 0) return "—"; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins === 0) return `${secs}s`; + return `${mins}m ${secs}s`; +} + +/* ── Component ────────────────────────────────────────────── */ + +function InvestigationSummaryPanel({ graph, selectedQuestion, result }) { + // ── Current status ──────────────────────────────────────────── + // TODO: reasoning should emit an explicit status field such as + // "investigating", "evidence_limit_reached", "resolution_achieved". + // Currently derived heuristically from graph state. + const isInvestigating = Boolean(selectedQuestion); + const hasGraph = Boolean(graph); + + let currentStatus; + if (!hasGraph) { + currentStatus = { label: "Not started", level: "idle" }; + } else if (isInvestigating) { + currentStatus = { label: "Investigation in progress", level: "investigating" }; + } else if (graph.resolvedNodeIds?.length > 0 && graph.nodes) { + const unresolvedUnknowns = graph.nodes.filter( + (n) => n.kind === "unknown" && !graph.resolvedNodeIds.includes(n.id) + ); + if (unresolvedUnknowns.length === 0) { + currentStatus = { label: "Investigation complete", level: "complete" }; + } else { + // TODO: reasoning should emit a terminal "evidence_limit_reached" + // status when it stops selecting questions because no unknown has + // sufficient upstream evidence. Currently we infer this from the + // absence of an active question combined with unresolved unknowns. + currentStatus = { label: "Current evidence limit reached", level: "limit" }; + } + } else { + currentStatus = { label: "Analysis complete", level: "complete" }; + } + + const statusColors = { + idle: { border: "border-gray-200", bg: "bg-gray-50", text: "text-gray-600" }, + investigating: { border: "border-blue-200", bg: "bg-blue-50", text: "text-blue-700" }, + complete: { border: "border-green-200", bg: "bg-green-50", text: "text-green-700" }, + limit: { border: "border-gray-300", bg: "bg-gray-100", text: "text-gray-500" }, + }; + + const colors = statusColors[currentStatus.level] || statusColors.idle; + + // ── Current understanding ──────────────────────────────── + // TODO: reasoning should provide a durable summary field that is + // guaranteed to be the latest plain-language synthesis. + // Currently falls back to graph.currentSummary which may not exist + // in all mock scenarios. + const currentUnderstanding = + result?.summary || + result?.updatedSituationGraph?.currentSummary || + graph?.currentSummary || + null; + + // ── Questions answered / remaining ─────────────────────── + // TODO: reasoning should emit a list of resolved unknown node IDs + // and the total set of unknown nodes it identified at start. + // Currently we count from the graph snapshot: every unknown whose + // status is "resolved" (or whose ID appears in resolvedNodeIds). + let questionsAnswered = 0; + let questionsRemaining = 0; + + if (graph?.nodes) { + const allUnknowns = graph.nodes.filter((n) => n.kind === "unknown"); + const resolvedCount = allUnknowns.filter( + (n) => n.status === "resolved" || (graph.resolvedNodeIds && graph.resolvedNodeIds.includes(n.id)) + ).length; + questionsAnswered = resolvedCount; + // TODO: this is a rough heuristic — the reasoning engine should + // explicitly track which unknowns were proposed for questioning. + questionsRemaining = allUnknowns.length - resolvedCount; + } + + // ── Timestamps ─────────────────────────────────────────── + // TODO: reasoning should provide investigationStartedAt and + // lastUpdatedAt as part of the start/update contract. + // Currently we use the session updatedAt timestamp (persisted by + // the UI layer) as a best-effort approximation. + const investigationStartTime = result?.updatedAt || null; + const lastUpdatedAt = result?.updatedAt || null; + + // Derive elapsed time since last update + let elapsedSeconds = 0; + if (lastUpdatedAt) { + elapsedSeconds = Math.floor((Date.now() - new Date(lastUpdatedAt).getTime()) / 1000); + } + + return ( +
+ {/* Status */} +
+ + {currentStatus.label} +
+ + {/* Current understanding */} + {currentUnderstanding && ( +
+

+ What we understand so far +

+

{currentUnderstanding}

+
+ )} + + {/* Questions */} +
+
+ Questions answered + {questionsAnswered} +
+
+ Still working on + {/* TODO: avoid implying 1 unknown = 1 remaining question */} + {isInvestigating ? ( + {questionsRemaining > 0 ? questionsRemaining + " items" : "—"} + ) : ( + + )} +
+
+ + {/* Timestamps */} +
+
+ Investigation started + {formatTimestamp(investigationStartTime)} +
+
+ Last updated + {formatTimestamp(lastUpdatedAt)} +
+ {elapsedSeconds > 0 && ( +
+ Elapsed since last update + {humaniseDuration(elapsedSeconds)} +
+ )} +
+
+ ); +} + +export default InvestigationSummaryPanel; diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index c93b42a..8aa7f7d 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -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 ( +
+

Provider unavailable

+

+ The reasoning service could not be reached. This is usually temporary — check that the local model is running and try again. +

+ {onRestart && ( + + )} +
+ ); +} + +function MalformedResponseCard({ onRestart }) { + return ( +
+

Unexpected response

+

+ The reasoning service returned a response we could not interpret. This may indicate a temporary issue with the model output format. +

+ {onRestart && ( + + )} +
+ ); +} + +function UnexpectedStateCard({ stateName, onRetry, onRestart }) { + return ( +
+

Unexpected state

+

+ {stateName ? `The system is in an unexpected state (${stateName}).` : "An unexpected internal error occurred."} + Please restart the investigation to continue. +

+
+ {onRetry && ( + + )} + {onRestart && ( + + )} +
+
+ ); +} + +function ContinueLaterBanner({ onRestart }) { + return ( +
+

+ Your previous investigation state is still saved. You can continue where you left off or start fresh. +

+ {onRestart && ( + + )} +
+ ); +} + +// ── 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 && ( + + )} + + {/* ── Malformed response recovery ────────────────── */} + {isMalformedResponse && ( + + )} + + {/* ── Unexpected state recovery ──────────────────── */} + {(errorType === "unexpected-state") && result && ( + + )} + {/* ── No graph produced after initial analysis ───────── */} {(status === "success" || status === "error") && !graph ? (
@@ -494,6 +671,9 @@ export default function ReasoningWorkspace({
) : ( <> + {/* ── Investigation summary card ─────────────── */} + + {/* ── 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 }; diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index e677f6f..109171e 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -3,8 +3,8 @@ import React, { useEffect } from "react"; import { useState, useRef, useMemo } from "react"; import DiagnosticsView from "@/components/diagnostics-view"; -import ReasoningWorkspace, { LoadingOverlay } from "@/components/reasoning-workspace"; -import { mockFetch } from "@/lib/mocks/confidence-engine/mock-client"; +import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace"; +import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client"; /* 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"; @@ -186,6 +186,27 @@ export function UpdateErrorPanel({ updateError }) { export { INITIAL_MESSAGES, UPDATE_MESSAGES, useLoadingStatus }; +// ── Session key ──────────────────────────────────────────────── +const SESSION_KEY = "confidence-engine-session"; + +function getSession() { + if (typeof sessionStorage === "undefined") return null; + try { + const raw = sessionStorage.getItem(SESSION_KEY); + return raw ? JSON.parse(raw) : null; + } catch (_) { return null; } +} + +function saveSession(state) { + if (typeof sessionStorage === "undefined") return; + try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(state)); } catch (_) {} +} + +function clearSession() { + if (typeof sessionStorage === "undefined") return; + try { sessionStorage.removeItem(SESSION_KEY); } catch (_) {} +} + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); const [status, setStatus] = useState("idle"); // idle | loading | error | success @@ -196,11 +217,52 @@ export default function ScenarioForm() { const [updateResult, setUpdateResult] = useState(null); const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState(""); const [currentUnderstanding, setCurrentUnderstanding] = useState(null); + const [mockScenario, setMockScenario] = useState(""); const textareaRef = useRef(null); + /* Restore persisted session on mount (Phase 3) ─────────── */ + useEffect(() => { + if (typeof window === "undefined") return; + const saved = getSession(); + if (!saved) return; + setScenario(saved.scenario || ""); + setResult(saved.situationGraph ? { ...saved, situationGraph: saved.situationGraph } : null); + setCurrentUnderstanding(saved.summary || null); + setStatus("success"); + }, []); + /* 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" @@ -230,7 +292,9 @@ export default function ScenarioForm() { if (res.ok && data.success) { setStatus("success"); setCurrentUnderstanding(data.summary ?? null); - setResult(normaliseStartResult(data)); + const normalised = normaliseStartResult(data); + setResult(normalised); + saveSession({ scenario, situationGraph: normalised.situationGraph, selectedQuestion: normalised.selectedQuestion, summary: data.summary ?? null, updatedAt: new Date().toISOString() }); } else { setStatus("error"); setCurrentUnderstanding(data.summary ?? null); @@ -292,6 +356,8 @@ export default function ScenarioForm() { diagnostics: outcome.diagnostics, })); setAnswer(""); + // Persist after successful update turn + saveSession({ scenario, situationGraph: outcome.updatedSituationGraph, selectedQuestion: normaliseUpdateSelectedQuestion(outcome.selectedQuestion), summary: outcome.summary ?? currentUnderstanding, updatedAt: new Date().toISOString() }); } else { setUpdateStatus("error"); setUpdateError(outcome); @@ -329,6 +395,43 @@ export default function ScenarioForm() { )} + {status === "idle" && MOCK_ENABLED && ( +
+ Developer details +
+
+ + +
+
+ {AVAILABLE_SCENARIOS.map(function(s) { + return ( + + ); + })} +
+
+
+ )} + {/* ── Initial analysis loading card ─────────────── */} {status === "loading" && ( { + clearSession(); + setStatus("idle"); + setResult(null); + setAnswer(""); + setUpdateStatus("idle"); + setUpdateResult(null); + setLastSubmittedAnswer(""); + setCurrentUnderstanding(null); + setUpdateError(null); + }} /> )} + {/* ── Continue later banner when session was restored ── */} + {status === "success" && result?.updatedAt && ( + { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); }} /> + )} + {/* Reset button after successful analysis */} {status === "success" && (