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:
@@ -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 (
|
||||
<div className={`rounded-lg border ${colors.border} ${colors.bg} p-5 space-y-4`}>
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2.5 w-2.5 rounded-full bg-current ${colors.text}`} />
|
||||
<span className={`text-sm font-medium ${colors.text}`}>{currentStatus.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Current understanding */}
|
||||
{currentUnderstanding && (
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
What we understand so far
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-gray-700">{currentUnderstanding}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Questions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="block text-xs text-gray-400">Questions answered</span>
|
||||
<span className={`text-lg font-semibold ${colors.text}`}>{questionsAnswered}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-gray-400">Still working on</span>
|
||||
{/* TODO: avoid implying 1 unknown = 1 remaining question */}
|
||||
{isInvestigating ? (
|
||||
<span className={`text-lg font-semibold ${colors.text}`}>{questionsRemaining > 0 ? questionsRemaining + " items" : "—"}</span>
|
||||
) : (
|
||||
<span className={`text-lg font-semibold ${colors.text}`}>—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-1 text-xs text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Investigation started</span>
|
||||
<span>{formatTimestamp(investigationStartTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Last updated</span>
|
||||
<span>{formatTimestamp(lastUpdatedAt)}</span>
|
||||
</div>
|
||||
{elapsedSeconds > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span>Elapsed since last update</span>
|
||||
<span>{humaniseDuration(elapsedSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InvestigationSummaryPanel;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status === "idle" && MOCK_ENABLED && (
|
||||
<details className="rounded-lg border border-gray-200 bg-gray-50">
|
||||
<summary className="cursor-pointer px-4 py-2 text-sm font-medium 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 bg-white px-3 py-1.5 text-xs text-gray-600 hover:bg-gray-100 disabled:opacity-30"
|
||||
>
|
||||
{s.label} ({s.turnCount} turns)
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* ── Initial analysis loading card ─────────────── */}
|
||||
{status === "loading" && (
|
||||
<LoadingOverlay
|
||||
@@ -358,14 +461,31 @@ export default function ScenarioForm() {
|
||||
setAnswer={setAnswer}
|
||||
onAnswerSubmit={handleUpdate}
|
||||
lastSubmittedAnswer={lastSubmittedAnswer}
|
||||
onRestart={() => {
|
||||
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 && (
|
||||
<ContinueLaterBanner onRestart={() => { clearSession(); setStatus("idle"); setResult(null); setAnswer(""); setUpdateStatus("idle"); setCurrentUnderstanding(null); }} />
|
||||
)}
|
||||
|
||||
{/* Reset button after successful analysis */}
|
||||
{status === "success" && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSession();
|
||||
setScenario("");
|
||||
setStatus("idle");
|
||||
setResult(null);
|
||||
|
||||
Reference in New Issue
Block a user